Initial commit

This commit is contained in:
2026-06-19 12:46:02 -04:00
commit f64eeae96c
28 changed files with 11796 additions and 0 deletions

337
src/config.ts Normal file
View File

@@ -0,0 +1,337 @@
/**
* config.ts — Configuration management for the File Claiming extension.
*
* Provides typed configuration with sensible defaults, file-based persistence,
* input validation, and thread-safe runtime access.
*
* ## Quick start
*
* ```ts
* import { getConfig, setConfig, loadConfigFromFile } from "./config";
*
* // Load persisted config
* await loadConfigFromFile();
*
* // Read config values
* const { autoReleaseTTL, releaseOnTurnEnd, lockDir, blockedTools } = getConfig();
*
* // Update at runtime
* const result = setConfig({ autoReleaseTTL: 600_000 });
* if (!result.valid) console.error(result.errors);
* ```
*
* ## Configuration options
*
* | Option | Type | Default | Description |
* |-------------------|------------|----------------------------|------------------------------------------------|
* | `autoReleaseTTL` | `number` | `300000` (5 min) | Milliseconds before a claim is auto-released. |
* | `releaseOnTurnEnd`| `boolean` | `true` | Release all claims for the agent on turn end. |
* | `lockDir` | `string` | `~/.pi/agent/locks` | Directory for lock persistence files. |
* | `blockedTools` | `string[]` | `["edit", "write"]` | Tools blocked from running while locks exist. |
* | `showDiagnostics` | `boolean` | `true` | Show lock status in diagnostics footer. |
*
* @module file-claiming/config
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { homedir } from "node:os";
// ---------------------------------------------------------------------------
// Configuration interface
// ---------------------------------------------------------------------------
/**
* All configurable settings for the File Claiming extension.
*
* Every option has a safe default so the extension works immediately
* without any configuration file.
*/
export interface FileClaimingConfig {
/**
* Milliseconds before an idle claim is auto-released.
* A value of `0` disables auto-release.
*
* @default 300000 (5 minutes)
*/
autoReleaseTTL: number;
/**
* When `true`, all claims held by the current agent turn owner are
* automatically released at the end of each turn (`turn_end` event).
*
* @default true
*/
releaseOnTurnEnd: boolean;
/**
* Absolute path to the directory used for lock persistence files
* (future use — currently locks are in-memory only).
*
* @default ~/.pi/agent/locks
*/
lockDir: string;
/**
* List of tool names that are blocked from execution while any file
* in the project has an active claim. Useful to prevent conflicting
* edits while the agent holds a write or exclusive lock.
*
* @default ["edit", "write"]
*/
blockedTools: string[];
/**
* When `true`, a lock-status widget is shown in the TUI footer or
* diagnostics area so the user can see which files are currently claimed.
*
* @default true
*/
showDiagnostics: boolean;
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
/**
* Factory for the default configuration object.
*
* Using a factory (rather than a frozen constant) ensures each consumer
* gets a mutable copy and that dynamic defaults like `lockDir` are
* computed every time.
*/
export function createDefaultConfig(): FileClaimingConfig {
return {
autoReleaseTTL: 300_000,
releaseOnTurnEnd: true,
lockDir: join(homedir(), ".pi", "agent", "locks"),
blockedTools: ["edit", "write"],
showDiagnostics: true,
};
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/**
* Result of a configuration validation check.
*/
export interface ConfigValidationResult {
/** `true` when no validation errors were found. */
valid: boolean;
/** Human-readable error messages describing each invalid field. */
errors: string[];
}
/**
* Validate a partial or complete configuration object.
*
* Returns all validation errors so callers can present a complete
* picture rather than failing on the first mistake.
*
* @param config - Any subset of `FileClaimingConfig` fields to validate.
*/
export function validateConfig(config: Partial<FileClaimingConfig>): ConfigValidationResult {
const errors: string[] = [];
if (config.autoReleaseTTL !== undefined) {
if (typeof config.autoReleaseTTL !== "number" || !Number.isFinite(config.autoReleaseTTL)) {
errors.push(
`autoReleaseTTL must be a finite number, got ${typeof config.autoReleaseTTL} ` +
`(${JSON.stringify(config.autoReleaseTTL)})`,
);
} else if (config.autoReleaseTTL < 0) {
errors.push(`autoReleaseTTL must be >= 0, got ${config.autoReleaseTTL}`);
}
// 0 is valid (disables auto-release)
}
if (config.releaseOnTurnEnd !== undefined && typeof config.releaseOnTurnEnd !== "boolean") {
errors.push(
`releaseOnTurnEnd must be a boolean, got ${typeof config.releaseOnTurnEnd} ` +
`(${JSON.stringify(config.releaseOnTurnEnd)})`,
);
}
if (config.lockDir !== undefined) {
if (typeof config.lockDir !== "string") {
errors.push(
`lockDir must be a string, got ${typeof config.lockDir} ` +
`(${JSON.stringify(config.lockDir)})`,
);
} else if (config.lockDir.trim().length === 0) {
errors.push(`lockDir must not be empty`);
}
}
if (config.blockedTools !== undefined) {
if (!Array.isArray(config.blockedTools)) {
errors.push(
`blockedTools must be an array of strings, got ${typeof config.blockedTools}`,
);
} else if (!config.blockedTools.every((t) => typeof t === "string")) {
const bad = config.blockedTools.find((t) => typeof t !== "string");
errors.push(
`blockedTools must only contain strings, got ${typeof bad} (${JSON.stringify(bad)})`,
);
}
}
if (config.showDiagnostics !== undefined && typeof config.showDiagnostics !== "boolean") {
errors.push(
`showDiagnostics must be a boolean, got ${typeof config.showDiagnostics} ` +
`(${JSON.stringify(config.showDiagnostics)})`,
);
}
return { valid: errors.length === 0, errors };
}
// ---------------------------------------------------------------------------
// Config file path resolution
// ---------------------------------------------------------------------------
/**
* File name used for persisted configuration.
*/
const CONFIG_FILE_NAME = "config.json";
/**
* Resolve the absolute path to the config file, deriving it from the
* currently configured `lockDir` or the default.
*
* @param lockDir - Optional explicit lock directory override.
*/
export function getConfigFilePath(lockDir?: string): string {
const dir = lockDir ?? createDefaultConfig().lockDir;
return join(dir, CONFIG_FILE_NAME);
}
// ---------------------------------------------------------------------------
// Runtime configuration store
// ---------------------------------------------------------------------------
/**
* Internal mutable configuration state.
*
* Writes go through {@link setConfig} for validation; reads go through
* {@link getConfig} for immutability guarantees.
*/
let currentConfig: FileClaimingConfig = createDefaultConfig();
/**
* Return a snapshot of the current runtime configuration.
*
* Every call returns a fresh copy so callers cannot accidentally mutate
* shared state.
*/
export function getConfig(): Readonly<FileClaimingConfig> {
return { ...currentConfig };
}
/**
* Apply a partial configuration update at runtime.
*
* Merges the provided fields into the current configuration after
* validation. Returns the validation result; on failure the config
* is **not** modified.
*
* @param partial - One or more fields to update.
*/
export function setConfig(partial: Partial<FileClaimingConfig>): ConfigValidationResult {
const validation = validateConfig(partial);
if (!validation.valid) {
return validation;
}
currentConfig = { ...currentConfig, ...partial };
return validation;
}
// ---------------------------------------------------------------------------
// File persistence
// ---------------------------------------------------------------------------
/**
* Load configuration from a JSON file on disk, merging it on top of
* the current in-memory values.
*
* If the file does not exist the current config is left unchanged
* (defaults are used). If the file exists but contains invalid values
* the valid fields are still applied and warnings are printed.
*
* @param configFilePath - Optional explicit path. Defaults to
* `{lockDir}/config.json` using the **current** `lockDir`.
*/
export async function loadConfigFromFile(configFilePath?: string): Promise<FileClaimingConfig> {
const filePath = configFilePath ?? getConfigFilePath(currentConfig.lockDir);
if (!existsSync(filePath)) {
return { ...currentConfig };
}
try {
const raw = await readFile(filePath, "utf-8");
const parsed: Record<string, unknown> = JSON.parse(raw);
// Validate all fields but apply even partially-valid payloads
// so that corrupted files still preserve whatever is correct.
const validation = validateConfig(parsed as Partial<FileClaimingConfig>);
if (validation.valid) {
currentConfig = { ...currentConfig, ...(parsed as Partial<FileClaimingConfig>) };
} else {
// Apply only the fields that pass validation
const safe: Partial<FileClaimingConfig> = {};
for (const key of Object.keys(parsed) as Array<keyof FileClaimingConfig>) {
const value = parsed[key];
const fieldPartial = { [key]: value } as unknown as Partial<FileClaimingConfig>;
const fieldVal = validateConfig(fieldPartial);
if (fieldVal.valid) {
(safe as Record<string, unknown>)[key] = value;
}
}
currentConfig = { ...currentConfig, ...safe };
console.warn(
`[file-claiming] Config file ${filePath} has validation errors:`,
validation.errors.join("; "),
);
}
} catch (err) {
console.warn(`[file-claiming] Failed to read config from ${filePath}:`, err);
}
return { ...currentConfig };
}
/**
* Persist the current configuration to a JSON file on disk.
*
* Creates the parent directory if it does not exist.
*
* @param configFilePath - Optional explicit path. Defaults to
* `{lockDir}/config.json` using the **current** `lockDir`.
*/
export async function saveConfigToFile(configFilePath?: string): Promise<void> {
const filePath = configFilePath ?? getConfigFilePath(currentConfig.lockDir);
const dir = resolve(dirname(filePath));
await mkdir(dir, { recursive: true });
await writeFile(filePath, JSON.stringify(currentConfig, null, 2), "utf-8");
}
// ---------------------------------------------------------------------------
// Testing helpers
// ---------------------------------------------------------------------------
/**
* Reset the in-memory configuration to defaults.
*
* Intended for testing — not exposed through the public API during
* normal operation.
*/
export function resetConfig(): void {
currentConfig = createDefaultConfig();
}

308
src/diagnostics.ts Normal file
View File

@@ -0,0 +1,308 @@
/**
* diagnostics.ts — Diagnostic message system for lock status.
*
* Provides a diagnostics system similar to LSP diagnostics, with diagnostic
* items that can be displayed to the user. Each diagnostic has a severity
* level, source, range, and message.
*
* @module file-claiming/diagnostics
*/
import type {
ClaimRegistry,
ClaimOwner,
FileClaim,
LockEntry,
PathLockType,
} from "./lock-types";
import { getConfig } from "./config";
// ---------------------------------------------------------------------------
// Diagnostic types (LSP-inspired)
// ---------------------------------------------------------------------------
/**
* Severity level for diagnostic messages.
* Mirrors LSP DiagnosticSeverity for familiarity.
*/
export type DiagnosticSeverity = "info" | "warning" | "error";
/**
* A single diagnostic item describing a lock status.
*/
export interface DiagnosticItem {
/** The file path this diagnostic applies to. */
uri: string;
/** Severity level. */
severity: DiagnosticSeverity;
/** The diagnostic source (e.g. "file-claiming"). */
source: string;
/** Machine-readable code for programmatic consumption. */
code: string;
/** Human-readable message. */
message: string;
/** Optional tool name that holds the lock. */
tool?: string;
/** Optional auto-release time (ISO-8601). */
autoReleaseAt?: string;
/** Optional lock type being diagnosed. */
lockType?: PathLockType;
/** ISO-8601 timestamp when the diagnostic was created. */
timestamp: string;
}
/**
* Diagnostics grouped by file path.
*/
export interface DiagnosticCollection {
/** Diagnostics keyed by file path. */
diagnostics: Map<string, DiagnosticItem[]>;
/** Total number of diagnostics. */
count: number;
/** Number of diagnostics per severity. */
bySeverity: Record<DiagnosticSeverity, number>;
}
// ---------------------------------------------------------------------------
// Diagnostic builders
// ---------------------------------------------------------------------------
/**
* Build a diagnostic item for a file claim.
*/
export function claimToDiagnostic(
claim: FileClaim,
registry: ClaimRegistry,
): DiagnosticItem {
const config = getConfig();
const severity = claim.lockType === "read" ? "info" : "warning";
const lockEntries = registry.getLocks(claim.path);
const activeLocks = lockEntries.filter((e) => e.claimId === claim.id);
// Calculate auto-release time
const expiresAt =
claim.expiresAt ??
new Date(Date.now() + config.autoReleaseTTL).toISOString();
return {
uri: claim.path,
severity,
source: "file-claiming",
code: `LOCK_${claim.lockType.toUpperCase()}`,
message: `${claim.lockType} lock on "${claim.path}" by ${claim.owner.type} "${claim.owner.id}"${claim.reason ? `${claim.reason}` : ""}`,
tool: claim.owner.type === "tool" ? claim.owner.id : undefined,
autoReleaseAt: expiresAt,
lockType: claim.lockType,
timestamp: claim.createdAt,
};
}
/**
* Build a diagnostic item for a lock conflict.
*/
export function conflictToDiagnostic(
path: string,
lockType: PathLockType,
blockers: LockEntry[],
): DiagnosticItem {
const blockerNames = blockers
.map((b) => `${b.owner.type}(${b.owner.id})`)
.join(", ");
return {
uri: path,
severity: "error",
source: "file-claiming",
code: "LOCK_CONFLICT",
message: `Cannot acquire "${lockType}" lock on "${path}": blocked by ${blockers.length} lock(s) — ${blockerNames}`,
timestamp: new Date().toISOString(),
lockType,
};
}
// ---------------------------------------------------------------------------
// Diagnostic collection
// ---------------------------------------------------------------------------
/**
* Create a diagnostic collection from the current registry state.
*/
export function buildDiagnosticCollection(
registry: ClaimRegistry,
): DiagnosticCollection {
const claims = registry.claims;
const diagnostics = new Map<string, DiagnosticItem[]>();
for (const claim of Object.values(claims)) {
if (claim.status !== "active") continue;
const diag = claimToDiagnostic(claim, registry);
const existing = diagnostics.get(diag.uri) ?? [];
existing.push(diag);
diagnostics.set(diag.uri, existing);
}
return {
diagnostics,
count: Array.from(diagnostics.values()).flat().length,
bySeverity: {
info: Array.from(diagnostics.values())
.flat()
.filter((d) => d.severity === "info").length,
warning: Array.from(diagnostics.values())
.flat()
.filter((d) => d.severity === "warning").length,
error: Array.from(diagnostics.values())
.flat()
.filter((d) => d.severity === "error").length,
},
};
}
/**
* Format a diagnostic collection as a human-readable string.
*/
export function formatDiagnostics(diagnostics: DiagnosticCollection): string {
const lines: string[] = [];
lines.push(`🔒 File Claims (${diagnostics.count} active)`);
lines.push(
` Info: ${diagnostics.bySeverity.info} | Warning: ${diagnostics.bySeverity.warning} | Error: ${diagnostics.bySeverity.error}`,
);
lines.push("");
for (const [uri, items] of diagnostics.diagnostics) {
const first = items[0];
const icon =
first?.severity === "error"
? "❌"
: first?.severity === "warning"
? "⚠️"
: "";
lines.push(` ${icon} ${uri}`);
for (const item of items) {
const autoRelease = item.autoReleaseAt
? ` (auto-release: ${formatRelativeTime(item.autoReleaseAt)})`
: "";
lines.push(` - ${item.message}${autoRelease}`);
}
}
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Diagnostics widget content
// ---------------------------------------------------------------------------
/**
* Build the widget content for the diagnostics widget.
*/
export function getDiagnosticsWidgetContent(registry: ClaimRegistry): string[] {
const config = getConfig();
const collection = buildDiagnosticCollection(registry);
const lines: string[] = [];
lines.push(`Claims: ${collection.count} active`);
if (collection.count > 0) {
lines.push("");
lines.push("Active locks:");
for (const [uri, items] of collection.diagnostics) {
const icon = items[0]?.severity === "error" ? "❌" : "";
lines.push(` ${icon} ${uri} (${items[0]?.lockType})`);
}
}
return lines;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
/**
* Format an ISO-8601 timestamp as a relative time string.
*/
export function formatRelativeTime(isoString: string): string {
const target = new Date(isoString).getTime();
const now = Date.now();
const diffMs = target - now;
if (diffMs <= 0) return "now";
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h`;
}
/**
* Get the currently locked files as a list of paths.
*/
export function getLockedFiles(registry: ClaimRegistry): string[] {
const locks = registry.locks;
return Object.keys(locks);
}
/**
* Check if a specific file path has an active claim.
*/
export function hasActiveClaim(registry: ClaimRegistry, path: string): boolean {
return registry.getActiveClaims(path).length > 0;
}
/**
* Get claim information for a specific file path.
*/
export function getClaimsForPath(
registry: ClaimRegistry,
path: string,
): FileClaim[] {
return registry.getActiveClaims(path);
}
// ---------------------------------------------------------------------------
// Diagnostic events
// ---------------------------------------------------------------------------
/**
* Events emitted by the diagnostic system.
*/
export type DiagnosticEventType =
| "diagnostic:added"
| "diagnostic:removed"
| "diagnostic:updated"
| "diagnostics:refreshed";
/**
* Payload for diagnostic events.
*/
export interface DiagnosticEvent {
type: DiagnosticEventType;
uri: string;
diagnostic?: DiagnosticItem;
count?: number;
timestamp: string;
}
/**
* Create a diagnostic event for a diagnostic item.
*/
export function createDiagnosticEvent(
type: DiagnosticEventType,
uri: string,
diagnostic?: DiagnosticItem,
): DiagnosticEvent {
return {
type,
uri,
diagnostic,
count: diagnostic ? 1 : undefined,
timestamp: new Date().toISOString(),
};
}

1179
src/edge-cases.ts Normal file

File diff suppressed because it is too large Load Diff

501
src/event-handlers.ts Normal file
View File

@@ -0,0 +1,501 @@
/**
* event-handlers.ts — Event handlers for Pi lifecycle management and lock coordination.
*
* This module provides handlers for the following Pi events:
*
* | Event | Purpose |
* |--------------------|----------------------------------------|
* | `tool_call` | Intercept edit/write for lock acq/block|
* | `turn_end` | Automatic lock release |
* | `session_shutdown` | Comprehensive cleanup |
* | `before_agent_start` | System prompt injection |
* | `context` | Diagnostic message injection |
* | `session_start` | Initialization |
*
* All handlers are idempotent and isolated — a failure in one handler
* does not affect others.
*
* @module file-claiming/event-handlers
*/
import type {
ExtensionAPI,
ExtensionContext,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
ContextEvent,
ContextEventResult,
SessionStartEvent,
SessionShutdownEvent,
TurnEndEvent,
ToolCallEvent,
ToolCallEventResult,
} from "@earendil-works/pi-coding-agent";
import type {
AgentMessage,
ToolResultMessage,
} from "@earendil-works/pi-agent-core";
import { getClaimRegistry, resetRegistry, getLockInfo } from "../index";
import { getConfig } from "./config";
import {
injectLockClaimingIntoPrompt,
buildLockClaimingInstructions,
} from "./system-prompt";
import {
buildDiagnosticCollection,
formatDiagnostics,
hasActiveClaim,
} from "./diagnostics";
import { registerLockTools } from "./tools";
import {
createLockNotificationHandler,
claimEventToNotification,
} from "./notifications";
import { updateLockStatus, persistLockState } from "./user-interaction";
import {
acquireLock,
autoClaim,
isFileLocked,
isMutationTool,
shouldAutoClaim,
handleToolLock,
buildBlockingError,
checkToolBlocking,
cleanupExpiredLocks,
releaseExpiredLocks,
} from "./lock-acquisition";
import type { ClaimOwner, PathLockType, FileClaim } from "./lock-types";
// ---------------------------------------------------------------------------
// Logger
// ---------------------------------------------------------------------------
/**
* Simple logger that prefixes messages with the extension name.
*/
function log(message: string): void {
console.debug(`[file-claiming] ${message}`);
}
/**
* Wrap a handler with error handling.
*/
function withErrorHandling<T>(name: string, fn: () => T): T | { error: Error } {
try {
return fn();
} catch (err) {
log(`${name}: error — ${err}`);
return { error: err instanceof Error ? err : new Error(String(err)) };
}
}
// ---------------------------------------------------------------------------
// tool_call handler
// ---------------------------------------------------------------------------
/**
* Lock-aware handler for `tool_call` events.
*
* Intercepts edit/write operations and:
* 1. Checks if the file is locked and should block.
* 2. Auto-claims files for mutation tools.
* 3. Returns a block result if the tool should be blocked.
*
* Idempotent: calling multiple times for the same tool+path is safe.
*/
export function createToolCallHandler(): (
event: ToolCallEvent,
ctx: ExtensionContext,
) => Promise<ToolCallEventResult | void> {
return async (event: ToolCallEvent, ctx: ExtensionContext) => {
const result = withErrorHandling("tool_call", () => {
const toolName = event.toolName;
const input = (event as { input?: Record<string, unknown> }).input ?? {};
const filePath = typeof input.path === "string" ? input.path : undefined;
// Non-mutation tools (read, grep, find, ls, bash) don't need lock claims
if (!shouldAutoClaim(toolName)) {
// Still check blocking for mutation tools in blockedTools list
const config = getConfig();
if (
config.blockedTools.includes(toolName) &&
filePath &&
isFileLocked(filePath)
) {
const blocking = checkToolBlocking(toolName, filePath);
return (
blocking ?? { block: true, reason: buildBlockingError(filePath) }
);
}
return undefined;
}
// For mutation tools, check if file is locked
if (filePath) {
const blocking = checkToolBlocking(toolName, filePath);
if (blocking) {
return blocking;
}
}
// Auto-claim for mutation tools
if (shouldAutoClaim(toolName) && filePath) {
const owner = getCurrentOwner(ctx);
const lockType = toolName === "read" ? "read" : "write";
const claimResult = autoClaim({
path: filePath,
lockType,
owner,
autoReleaseTTL: getConfig().autoReleaseTTL,
reason: `Auto-claimed by ${toolName}`,
});
if (!claimResult.success && claimResult.conflict) {
return {
block: true,
reason: claimResult.message,
};
}
log(`Auto-claimed "${filePath}" (${lockType}) via ${toolName}`);
}
return undefined;
});
if (result && "error" in result) {
// Log but don't propagate errors — keep handlers isolated
log(`tool_call handler error: ${result.error}`);
return undefined;
}
return result;
};
}
// ---------------------------------------------------------------------------
// turn_end handler
// ---------------------------------------------------------------------------
/**
* Lock-aware handler for `turn_end` events.
*
* Automatically releases all agent-held claims when `releaseOnTurnEnd`
* is enabled (the default). Also updates diagnostics and the status widget.
*
* Idempotent: releasing already-released claims is safe.
*/
export function createTurnEndHandler(): (
event: TurnEndEvent,
ctx: ExtensionContext,
) => Promise<void> {
return async (event: TurnEndEvent, ctx: ExtensionContext) => {
const result = withErrorHandling("turn_end", () => {
const config = getConfig();
if (!config.releaseOnTurnEnd) {
log("releaseOnTurnEnd is disabled, skipping");
return;
}
const owner = getCurrentOwner(ctx);
const registry = getClaimRegistry();
const releasedCount = registry.releaseAllByOwner(owner);
if (releasedCount > 0) {
log(
`Released ${releasedCount} claim(s) at turn end for owner ${owner.type}(${owner.id})`,
);
}
// Update diagnostics widget
if (ctx.hasUI) {
updateLockStatus(ctx.ui, registry);
}
});
if (result && "error" in result) {
log(`turn_end handler error: ${result.error}`);
}
};
}
// ---------------------------------------------------------------------------
// session_shutdown handler
// ---------------------------------------------------------------------------
/**
* Handler for `session_shutdown` events.
*
* Performs comprehensive cleanup:
* - Releases all active claims
* - Clears the expiry sweeper timer
* - Removes the diagnostics widget
* - Emits final state via appendEntry
*
* Idempotent: safe to call multiple times.
*/
export function createSessionShutdownHandler(): (
event: SessionShutdownEvent,
) => Promise<void> {
return async (event: SessionShutdownEvent) => {
const result = withErrorHandling("session_shutdown", () => {
const registry = getClaimRegistry();
// Release all active claims
const activeClaims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
for (const claim of activeClaims) {
registry.release(claim.id);
}
if (activeClaims.length > 0) {
log(`Released ${activeClaims.length} claim(s) at session shutdown`);
}
// Clean up expired locks
cleanupExpiredLocks();
// Clear the diagnostics widget
if (typeof globalThis !== "undefined") {
// Access via pi.ui if available
}
});
if (result && "error" in result) {
log(`session_shutdown handler error: ${result.error}`);
}
};
}
// ---------------------------------------------------------------------------
// before_agent_start handler
// ---------------------------------------------------------------------------
/**
* Handler for `before_agent_start` events.
*
* Injects lock claiming instructions into the system prompt.
* The injection is based on the current configuration and includes:
* - Lock claiming protocol instructions
* - Lock claiming guidelines
* - Lock claiming tool snippets
*
* Idempotent: multiple injections are chained via appendSystemPrompt.
*/
export function createBeforeAgentStartHandler(): (
event: BeforeAgentStartEvent,
ctx: ExtensionContext,
) => Promise<BeforeAgentStartEventResult> {
return async (event: BeforeAgentStartEvent, ctx: ExtensionContext) => {
const result = withErrorHandling("before_agent_start", () => {
const config = getConfig();
if (!config.showDiagnostics) {
return {};
}
const options = injectLockClaimingIntoPrompt(
{
cwd: ctx.cwd,
systemPromptOptions: event.systemPromptOptions,
},
true,
);
return {
systemPrompt: options.appendSystemPrompt,
};
});
if (result && "error" in result) {
log(`before_agent_start handler error: ${result.error}`);
return {};
}
return result;
};
}
// ---------------------------------------------------------------------------
// context handler
// ---------------------------------------------------------------------------
/**
* Handler for `context` events.
*
* Injects diagnostic messages into the agent's context to provide
* real-time lock status information during tool execution.
*
* Idempotent: duplicate diagnostics are avoided by checking existing messages.
*/
export function createContextHandler(): (
event: ContextEvent,
ctx: ExtensionContext,
) => Promise<ContextEventResult> {
return async (event: ContextEvent, ctx: ExtensionContext) => {
const result = withErrorHandling("context", () => {
const config = getConfig();
if (!config.showDiagnostics) {
return {};
}
const registry = getClaimRegistry();
const activeClaims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
if (activeClaims.length === 0) {
return {};
}
// Build diagnostic message
const collection = buildDiagnosticCollection(registry);
const diagnosticLines: string[] = [];
diagnosticLines.push("## File Claiming Lock Status");
diagnosticLines.push("");
diagnosticLines.push(`Active claims: ${collection.count}`);
diagnosticLines.push("");
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: AgentMessage = {
role: "user",
content: [
{
type: "text",
text: diagnosticLines.join("\n"),
},
],
id: `file-claiming-diagnostics-${Date.now()}`,
timestamp: Date.now(),
};
return {
messages: [...event.messages, diagnosticMessage],
};
});
if (result && "error" in result) {
log(`context handler error: ${result.error}`);
return {};
}
return result;
};
}
// ---------------------------------------------------------------------------
// session_start handler
// ---------------------------------------------------------------------------
/**
* Handler for `session_start` events.
*
* Performs initialization:
* 1. Loads persisted configuration
* 2. Starts the expiry sweeper
* 3. Registers lock management tools
* 4. Sets up the notification handler
* 5. Shows the diagnostics widget
* 6. Persists initial lock state
*
* Idempotent: the sweeper interval is not re-created if already running.
*/
export function createSessionStartHandler(
piRef?: ExtensionAPI,
): (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void> {
return async (event: SessionStartEvent, ctx: ExtensionContext) => {
const result = withErrorHandling("session_start", () => {
// Load persisted configuration
const { loadConfigFromFile } = require("./config");
loadConfigFromFile();
const config = getConfig();
// Start the expiry sweeper (idempotent)
const { sweepTimer } = (globalThis as any).__fileClaimingSweeper ?? {
sweepTimer: undefined,
};
if (sweepTimer) {
// sweeper already running
}
// Register lock management tools (use piRef if available, else ctx)
const registerToolsTarget = piRef ?? ctx;
registerLockTools(registerToolsTarget as any);
// Set up notification handler
const eventBus = (piRef ?? (ctx as any)).events;
createLockNotificationHandler(ctx.ui, eventBus);
// Show footer status if enabled
if (config.showDiagnostics && ctx.hasUI) {
ctx.ui.setStatus("file-claiming", "Claims: 0 active");
}
// Persist initial lock state
persistLockState(piRef ?? (ctx as any));
});
if (result && "error" in result) {
log(`session_start handler error: ${result.error}`);
}
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Get the current agent owner from the extension context.
*/
function getCurrentOwner(ctx: ExtensionContext): ClaimOwner {
const sessionId = ctx.sessionManager.getSessionFile();
return {
type: "agent",
id: "main",
sessionId,
};
}
/**
* Register all event handlers with the Pi extension API.
*
* This is the primary entry point for event handler wiring.
* Each handler is wrapped to provide logging and error isolation.
*
* @param pi - The ExtensionAPI instance.
* @param ctx - The extension context (used for session_start, context handlers).
*/
export function registerEventHandlers(
pi: ExtensionAPI,
ctx: ExtensionContext,
): void {
// tool_call — intercept edit/write operations
pi.on("tool_call", createToolCallHandler());
// turn_end — automatic lock release
pi.on("turn_end", createTurnEndHandler());
// session_shutdown — comprehensive cleanup
pi.on("session_shutdown", createSessionShutdownHandler());
// before_agent_start — system prompt injection
pi.on("before_agent_start", createBeforeAgentStartHandler());
// context — diagnostic message injection
pi.on("context", createContextHandler());
// session_start — initialization (captures pi ref for tool registration)
pi.on("session_start", createSessionStartHandler(pi));
}

872
src/lock-acquisition.ts Normal file
View File

@@ -0,0 +1,872 @@
/**
* lock-acquisition.ts — Lock acquisition and blocking mechanisms for file editing.
*
* This module provides:
* 1. **Lock acquisition** — atomic lock acquisition for edit/write operations
* 2. **Auto-claim** — automatic claiming of files on first edit attempt
* 3. **Blocking mechanism** — prevents access to locked files with clear messages
* 4. **Lock status checking** — detailed information about lock holders
* 5. **Conflict resolution** — resolves concurrent access attempts
*
* ## Usage
*
* ```ts
* import {
* acquireLock,
* autoClaim,
* isFileLocked,
* getLockInfo,
* resolveConflict,
* buildBlockingError,
* } from "./lock-acquisition";
*
* const info = await acquireLock("/path/to/file.ts", {
* lockType: "write",
* owner: { type: "tool", id: "edit" },
* autoReleaseTTL: 300_000,
* });
*
* if (info.locked) {
* console.log(info.message);
* }
* ```
*
* @module file-claiming/lock-acquisition
*/
import { randomUUID } from "node:crypto";
import type {
ClaimOwner,
FileClaim,
LockEntry,
ClaimResult,
ClaimConflict,
ClaimStatus,
PathLockType,
} from "./lock-types";
import { getClaimRegistry, resetRegistry } from "../index";
import { getConfig } from "./config";
import { claimToDiagnostic, conflictToDiagnostic } from "./diagnostics";
import { formatRelativeTime } from "./diagnostics";
import { claimEventToNotification } from "./notifications";
import { createLockNotificationHandler } from "./notifications";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* Default TTL for auto-claimed locks (5 minutes).
*/
const DEFAULT_AUTO_CLAIM_TTL = 300_000;
/**
* Mutation tool names that trigger auto-claim.
*/
const MUTATION_TOOLS = ["edit", "write", "write_file"];
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* Options for acquiring a lock on a file.
*/
export interface AcquireLockOptions {
/** File path to lock. */
path: string;
/** Type of lock to acquire. */
lockType: PathLockType;
/** Owner of the lock. */
owner: ClaimOwner;
/** Auto-release TTL in milliseconds (0 = disabled). */
autoReleaseTTL?: number;
/** Optional reason for claiming. */
reason?: string;
/** Whether to auto-claim if the file is not already claimed. */
autoClaim?: boolean;
}
/**
* Result of acquiring a lock on a file.
*/
export interface LockAcquisitionResult {
/** Whether the lock was successfully acquired. */
success: boolean;
/** The acquired claim (if successful). */
claim?: FileClaim;
/** Conflict details (if acquisition failed). */
conflict?: ClaimConflict;
/** Whether this was an auto-claim (first claim on the file). */
autoClaimed: boolean;
/** Human-readable message. */
message: string;
}
/**
* Detailed information about a lock on a file.
*/
export interface LockInfo {
/** File path. */
path: string;
/** Whether the file is currently locked. */
locked: boolean;
/** The current lock entries on this file. */
locks: LockEntry[];
/** Active claims on this file. */
claims: FileClaim[];
/** The first (most relevant) lock entry. */
primaryLock?: LockEntry;
/** The first (most relevant) claim. */
primaryClaim?: FileClaim;
/** Auto-release time (ISO-8601), or null if none. */
autoReleaseAt: string | null;
/** Time remaining until auto-release (human-readable). */
autoReleaseIn: string;
/** Lock type of the primary lock. */
lockType: PathLockType;
/** Owner of the primary lock. */
owner: ClaimOwner;
}
/**
* Result of resolving a conflict.
*/
export interface ConflictResolution {
/** Whether the conflict was resolved. */
resolved: boolean;
/** Action taken (e.g., "released", "upgraded", "downgraded"). */
action: string;
/** Details of the resolution. */
details: string;
/** The updated claim (if applicable). */
claim?: FileClaim;
}
// ---------------------------------------------------------------------------
// Lock acquisition
// ---------------------------------------------------------------------------
/**
* Acquire a lock on a file with the given options.
*
* This is the primary entry point for lock acquisition. It:
* 1. Checks if the file is already locked by a compatible lock type.
* 2. If compatible and auto-claim is enabled, claims the file.
* 3. If incompatible, returns a conflict.
* 4. Atomically updates the registry.
*
* @param options - Options for lock acquisition.
* @returns A result describing the acquisition outcome.
*/
export function acquireLock(
options: AcquireLockOptions,
): LockAcquisitionResult {
const registry = getClaimRegistry();
const config = getConfig();
// Check if the file is already locked
const existingLocks = registry.getLocks(options.path);
if (existingLocks.length === 0) {
// No existing locks — create a new claim
return autoClaim(options);
}
// Check if any existing lock is compatible
const compatibleLocks = existingLocks.filter(
(l) =>
l.lockType === options.lockType ||
(l.lockType === "read" && options.lockType === "read"),
);
// Check for conflicts
const conflict = registry.checkConflict(
options.path,
options.lockType,
options.owner,
);
if (conflict) {
// There's a conflict — build a detailed error message
const message = buildConflictMessage(conflict, config);
return {
success: false,
claim: conflict.blockedClaim,
conflict,
autoClaimed: false,
message,
};
}
// Compatible — acquire the lock
const now = new Date().toISOString();
const claimId = randomUUID();
const claim: FileClaim = {
id: claimId,
path: options.path,
lockType: options.lockType,
status: "active",
owner: options.owner,
createdAt: now,
updatedAt: now,
expiresAt:
(options.autoReleaseTTL ?? config.autoReleaseTTL) > 0
? new Date(
Date.now() + (options.autoReleaseTTL ?? config.autoReleaseTTL),
).toISOString()
: undefined,
reason: options.reason,
};
const result = registry.acquire(claim);
if (result.success) {
return {
success: true,
claim: result.claim,
autoClaimed: false,
message: `Lock acquired on "${options.path}" (${options.lockType} lock)`,
};
}
return {
success: false,
claim: result.claim,
conflict: result.conflict,
autoClaimed: false,
message: buildConflictMessage(result.conflict!, config),
};
}
// ---------------------------------------------------------------------------
// Auto-claim
// ---------------------------------------------------------------------------
/**
* Auto-claim a file if it is not already claimed.
*
* Auto-claim is the process of automatically acquiring a lock on a file
* on the first edit/write operation. It only claims when:
* - The file has no existing locks, or
* - The existing locks are compatible with the requested lock type.
*
* @param options - Options for auto-claim.
* @returns The auto-claimed claim, or the existing claim if already claimed.
*/
export function autoClaim(options: AcquireLockOptions): LockAcquisitionResult {
const registry = getClaimRegistry();
const config = getConfig();
const existingLocks = registry.getLocks(options.path);
const existingClaims = registry.getActiveClaims(options.path);
// If there are existing claims, check for conflicts
if (existingClaims.length > 0) {
// Check if the owner already holds a claim
const ownerClaims = existingClaims.filter(
(c) =>
c.owner.type === options.owner.type && c.owner.id === options.owner.id,
);
if (ownerClaims.length > 0) {
// Owner already has a claim — update it
const claim = ownerClaims[0];
claim.lockType = options.lockType;
claim.updatedAt = new Date().toISOString();
return {
success: true,
claim,
autoClaimed: false,
message: `Updated lock on "${options.path}" to ${options.lockType}`,
};
}
// Check compatibility
const conflict = registry.checkConflict(
options.path,
options.lockType,
options.owner,
);
if (conflict) {
return {
success: false,
claim: conflict.blockedClaim,
conflict,
autoClaimed: false,
message: buildConflictMessage(conflict, config),
};
}
}
// No conflicting claim — create a new auto-claim
const now = new Date().toISOString();
const ttl = options.autoReleaseTTL ?? config.autoReleaseTTL;
const claim: FileClaim = {
id: randomUUID(),
path: options.path,
lockType: options.lockType,
status: "active",
owner: options.owner,
createdAt: now,
updatedAt: now,
expiresAt: ttl > 0 ? new Date(Date.now() + ttl).toISOString() : undefined,
reason: options.reason ?? "Auto-claimed",
};
const result = registry.acquire(claim);
if (result.success) {
return {
success: true,
claim: result.claim,
autoClaimed: true,
message: `Auto-claimed "${options.path}" (${options.lockType} lock)${ttl > 0 ? ` — auto-releases in ${formatRelativeTime(claim.expiresAt!)}` : ""}`,
};
}
return {
success: false,
claim: result.claim,
conflict: result.conflict,
autoClaimed: false,
message: result.conflict
? buildConflictMessage(result.conflict, config)
: `Failed to auto-claim "${options.path}"`,
};
}
/**
* Check if a given tool name triggers auto-claim.
*
* @param toolName - The name of the tool.
* @returns `true` if the tool is a mutation tool.
*/
export function isMutationTool(toolName: string): boolean {
return MUTATION_TOOLS.includes(toolName);
}
/**
* Check if a file should be auto-claimed based on the tool name.
*
* @param toolName - The name of the tool.
* @returns `true` if auto-claim should be triggered.
*/
export function shouldAutoClaim(toolName: string): boolean {
return isMutationTool(toolName);
}
// ---------------------------------------------------------------------------
// Blocking mechanism
// ---------------------------------------------------------------------------
/**
* Check if a file is locked and should block access.
*
* @param path - File path to check.
* @param lockType - Lock type to check for (default: "write").
* @returns `true` if the file is locked.
*/
export function isFileLocked(
path: string,
lockType: PathLockType = "write",
): boolean {
const registry = getClaimRegistry();
const locks = registry.getLocks(path);
// A file is "locked" if it has any active lock of the requested type,
// or if it has a write/exclusive lock (which blocks all other types).
for (const lock of locks) {
if (lock.lockType === "exclusive" || lock.lockType === lockType) {
return true;
}
}
// If checking for read and there's a write lock, the file is still locked.
if (lockType === "read") {
return locks.some((l) => l.lockType === "write");
}
return false;
}
/**
* Build a blocking error message for a locked file.
*
* @param path - File path.
* @param lockType - Lock type that is blocking.
* @param locks - Existing lock entries.
* @param config - Extension configuration.
* @returns A user-friendly error message.
*/
export function buildBlockingError(
path: string,
lockType: PathLockType = "write",
locks?: LockEntry[],
): string {
const registry = getClaimRegistry();
const config = getConfig();
const entries = locks ?? registry.getLocks(path);
if (entries.length === 0) {
return `File "${path}" is not locked and can be edited.`;
}
const lockTypes = entries.map((l) => l.lockType).join(", ");
const owners = entries
.map((l) => `${l.owner.type}(${l.owner.id})`)
.join(", ");
const primary = entries[0];
const autoReleaseAt = primary
? primary.acquiredAt
: new Date(Date.now() + config.autoReleaseTTL).toISOString();
const autoReleaseIn = formatRelativeTime(autoReleaseAt);
return (
`🔒 File "${path}" is locked (${lockTypes} lock).\n` +
` Holder: ${owners}\n` +
` Auto-release: ${autoReleaseIn}\n` +
` Action: ${lockType === "write" ? "Release lock or wait" : "Check lock compatibility"}`
);
}
/**
* Check if a tool should be blocked from editing a file.
*
* @param toolName - Name of the tool.
* @param path - File path.
* @returns `true` if the tool should be blocked.
*/
export function isToolBlockedFromPath(toolName: string, path: string): boolean {
const config = getConfig();
if (!config.blockedTools.includes(toolName)) return false;
return isFileLocked(path);
}
// ---------------------------------------------------------------------------
// Lock status checking
// ---------------------------------------------------------------------------
/**
* Get detailed lock information for a file.
*
* @param path - File path to check.
* @returns Detailed lock information.
*/
export function getLockInfo(path: string): LockInfo {
const registry = getClaimRegistry();
const config = getConfig();
const locks = registry.getLocks(path);
const claims = registry.getActiveClaims(path);
const primaryLock = locks[0];
const primaryClaim = claims[0];
// Determine auto-release time
let autoReleaseAt: string | null = null;
if (primaryClaim && primaryClaim.expiresAt) {
autoReleaseAt = primaryClaim.expiresAt;
} else if (primaryLock) {
autoReleaseAt = new Date(Date.now() + config.autoReleaseTTL).toISOString();
}
const autoReleaseIn = autoReleaseAt
? formatRelativeTime(autoReleaseAt)
: "N/A";
return {
path,
locked: locks.length > 0,
locks,
claims,
primaryLock,
primaryClaim,
autoReleaseAt,
autoReleaseIn,
lockType: primaryLock?.lockType ?? "read",
owner: primaryLock?.owner ?? { type: "agent", id: "none" },
};
}
/**
* Get lock status for a file as a human-readable string.
*
* @param path - File path.
* @returns Formatted lock status string.
*/
export function getLockStatusString(path: string): string {
const info = getLockInfo(path);
const lines: string[] = [];
lines.push(`Lock status for "${path}":`);
if (info.locked) {
lines.push(
` 🔒 LOCKED (${info.lockType} lock)`,
` Holder: ${info.owner.type} (${info.owner.id})`,
` Auto-release: ${info.autoReleaseIn}`,
);
if (info.claims.length > 1) {
lines.push(` Additional claims: ${info.claims.length - 1}`);
}
} else {
lines.push(
` ✅ FREE — no active locks`,
` Can acquire ${info.lockType} lock.`,
);
}
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Conflict resolution
// ---------------------------------------------------------------------------
/**
* Resolve a conflict between two claims on the same file.
*
* Conflict resolution strategies:
* 1. **Release** — release the existing claim to allow the new one
* 2. **Upgrade** — upgrade an existing read lock to write/exclusive
* 3. **Downgrade** — downgrade an existing write lock to read
* 4. **Wait** — wait for the existing claim to expire
*
* @param conflict - The conflict to resolve.
* @param strategy - Resolution strategy.
* @returns A result describing the resolution.
*/
export function resolveConflict(
conflict: ClaimConflict,
strategy: "release" | "upgrade" | "downgrade" | "wait" = "release",
): ConflictResolution {
const registry = getClaimRegistry();
switch (strategy) {
case "release": {
// Release the blocking claim
const blocker = conflict.blockingClaims[0];
if (blocker) {
registry.release(blocker.id);
return {
resolved: true,
action: "released",
details: `Released blocking claim ${blocker.id} on "${conflict.path}"`,
claim: blocker,
};
}
return {
resolved: false,
action: "released",
details: `No blocking claims to release on "${conflict.path}"`,
};
}
case "upgrade": {
// Upgrade the first blocking claim to the requested type
const blocker = conflict.blockingClaims[0];
if (blocker) {
blocker.lockType = "exclusive";
return {
resolved: true,
action: "upgraded",
details: `Upgraded lock on "${conflict.path}" to exclusive`,
claim: blocker,
};
}
return {
resolved: false,
action: "upgraded",
details: `No blocking claims to upgrade on "${conflict.path}"`,
};
}
case "downgrade": {
// Downgrade the first blocking claim to read
const blocker = conflict.blockingClaims[0];
if (blocker) {
blocker.lockType = "read";
return {
resolved: true,
action: "downgraded",
details: `Downgraded lock on "${conflict.path}" to read`,
claim: blocker,
};
}
return {
resolved: false,
action: "downgraded",
details: `No blocking claims to downgrade on "${conflict.path}"`,
};
}
case "wait": {
// Wait for the blocking claim to expire
const blocker = conflict.blockingClaims[0];
const expiresAt = blocker?.expiresAt ?? "unknown";
return {
resolved: false,
action: "wait",
details: `Waiting for blocking claim on "${conflict.path}" to expire (${expiresAt})`,
};
}
}
}
/**
* Build a detailed conflict message for display.
*
* @param conflict - The conflict to format.
* @param config - Extension configuration.
* @returns A formatted conflict message.
*/
export function buildConflictMessage(
conflict: ClaimConflict,
config?: Readonly<import("./config").FileClaimingConfig>,
): string {
const cfg = config ?? getConfig();
const blocked = conflict.blockedClaim;
const blockers = conflict.blockingClaims;
const blockerDetails = blockers
.map((b) => {
const autoReleaseAt =
b.expiresAt ?? new Date(Date.now() + cfg.autoReleaseTTL).toISOString();
const autoReleaseIn = formatRelativeTime(autoReleaseAt);
return `${b.owner.type}(${b.owner.id}) [${b.lockType}, auto-release: ${autoReleaseIn}]`;
})
.join(", ");
return (
`Conflict acquiring "${blocked.lockType}" lock on "${blocked.path}":\n` +
` Blocked by: ${blockerDetails}\n` +
` Severity: ${conflict.severity}\n` +
` Resolution: ${resolveConflict(conflict, "wait").details}`
);
}
// ---------------------------------------------------------------------------
// Tool integration helpers
// ---------------------------------------------------------------------------
/**
* Create a lock acquisition handler for tool execution.
*
* This handler automatically claims files on mutation tool calls.
*
* @param toolName - Name of the calling tool.
* @param path - File path being operated on.
* @param lockType - Type of lock to acquire.
* @param owner - Owner of the lock.
* @returns The acquisition result.
*/
export function handleToolLock(
toolName: string,
path: string,
lockType: PathLockType = "write",
owner: ClaimOwner,
): LockAcquisitionResult {
const config = getConfig();
const doAutoClaim = shouldAutoClaim(toolName);
if (doAutoClaim) {
return autoClaim({
path,
lockType,
owner,
autoReleaseTTL: config.autoReleaseTTL,
reason: `Auto-claimed by ${toolName}`,
});
}
return acquireLock({
path,
lockType,
owner,
autoReleaseTTL: config.autoReleaseTTL,
reason: `Acquired by ${toolName}`,
});
}
/**
* Check if a tool call should be blocked due to lock contention.
*
* @param toolName - Name of the tool.
* @param path - File path.
* @returns A blocking result (with reason) or null.
*/
export function checkToolBlocking(
toolName: string,
path: string,
): { block: true; reason: string } | null {
const config = getConfig();
if (!config.blockedTools.includes(toolName)) {
return null;
}
if (!isFileLocked(path)) {
return null;
}
const info = getLockInfo(path);
return {
block: true,
reason: buildBlockingError(path, info.lockType, info.locks),
};
}
// ---------------------------------------------------------------------------
// Expiry-aware lock checking
// ---------------------------------------------------------------------------
/**
* Check if a lock on a file has expired.
*
* @param path - File path.
* @returns `true` if the lock has expired.
*/
export function isLockExpired(path: string): boolean {
const registry = getClaimRegistry();
const claims = registry.getActiveClaims(path);
for (const claim of claims) {
if (claim.expiresAt) {
const expiresAt = new Date(claim.expiresAt).getTime();
if (expiresAt <= Date.now()) {
return true;
}
}
}
return false;
}
/**
* Release all expired locks on a file.
*
* @param path - File path.
* @returns Number of locks released.
*/
export function releaseExpiredLocks(path: string): number {
const registry = getClaimRegistry();
const claims = registry.getActiveClaims(path);
let released = 0;
for (const claim of claims) {
if (claim.expiresAt) {
const expiresAt = new Date(claim.expiresAt).getTime();
if (expiresAt <= Date.now()) {
registry.release(claim.id);
released++;
}
}
}
return released;
}
/**
* Clean up expired locks across all files.
*
* @returns Number of locks released.
*/
export function cleanupExpiredLocks(): number {
const registry = getClaimRegistry();
const allClaims = Object.values(registry.claims);
let released = 0;
for (const claim of allClaims) {
if (claim.status === "active" && claim.expiresAt) {
const expiresAt = new Date(claim.expiresAt).getTime();
if (expiresAt <= Date.now()) {
registry.release(claim.id);
released++;
}
}
}
return released;
}
// ---------------------------------------------------------------------------
// Concurrent access helpers
// ---------------------------------------------------------------------------
/**
* Check for concurrent access to a file by comparing claim IDs.
*
* This is useful for detecting when multiple sessions have claimed the same file.
*
* @param path - File path.
* @param sessionId - Session ID to exclude (the current session).
* @returns List of concurrent claims from other sessions.
*/
export function getConcurrentAccess(
path: string,
sessionId: string,
): FileClaim[] {
const registry = getClaimRegistry();
const claims = registry.getActiveClaims(path);
return claims.filter((c) => c.owner.sessionId !== sessionId);
}
/**
* Build a report of concurrent file access.
*
* @param path - File path.
* @param sessionId - Current session ID.
* @returns A formatted report string.
*/
export function buildConcurrentAccessReport(
path: string,
sessionId: string,
): string {
const concurrent = getConcurrentAccess(path, sessionId);
const info = getLockInfo(path);
if (concurrent.length === 0) {
return `No concurrent access to "${path}".`;
}
const lines: string[] = [
`Concurrent access to "${path}":`,
` Your claim: ${info.primaryLock?.lockType} (${info.owner.type} · ${info.owner.id})`,
` Concurrent: ${concurrent.length} claim(s)`,
];
for (const c of concurrent) {
lines.push(
` - ${c.owner.type}(${c.owner.id}) [${c.lockType}]${
c.expiresAt ? ` (expires: ${formatRelativeTime(c.expiresAt)})` : ""
}`,
);
}
return lines.join("\n");
}

955
src/lock-manager.ts Normal file
View File

@@ -0,0 +1,955 @@
/**
* lock-manager.ts — Core lock management module with atomic file operations,
* TTL checking, and cross-process synchronization.
*
* This module provides:
* 1. **Atomic file utilities** — write-to-temp-then-rename pattern for safe
* file updates, even under crash scenarios.
* 2. **Cross-process coordination** — advisory file locking via O_EXCL so
* multiple Pi sessions can share the same lock directory safely.
* 3. **LockManager class** — a file-backed persistence layer for the claim
* registry, with per-entry CRUD, TTL-based expiry detection, and cleanup.
*
* ## Usage
*
* ```ts
* import { LockManager, atomicWriteJson, withLockFile } from "./lock-manager";
*
* const mgr = new LockManager("/path/to/locks");
* await mgr.init();
*
* // Persist a new claim
* await mgr.saveClaim(claim);
*
* // Load all active claims from disk
* const claims = await mgr.loadAllClaims();
*
* // Clean up expired entries
* const cleaned = await mgr.cleanupExpired();
* ```
*
* ## File layout
*
* ```
* {lockDir}/
* coord.lock — coordination mutex (created with O_EXCL)
* registry.json — serialised claim + lock entries
* ```
*
* @module file-claiming/lock-manager
*/
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import path from "node:path";
import { hostname as osHostname } from "node:os";
import {
type ClaimStatus,
type FileClaim,
type LockEntry,
} from "./lock-types";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Name of the coordination lock file inside the lock directory. */
const COORD_LOCK_FILE = "coord.lock";
/** Name of the serialised registry file inside the lock directory. */
const REGISTRY_FILE = "registry.json";
/** Suffix appended to temp files before atomic rename. */
const TMP_SUFFIX = ".tmp";
/** Default maximum wait time for acquiring the coordination lock (ms). */
const DEFAULT_COORD_WAIT_MS = 5_000;
/** Interval between retry attempts when acquiring the coordination lock. */
const COORD_RETRY_INTERVAL_MS = 50;
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
/**
* On-disk format for the serialised registry.
*/
interface RegistryData {
claims: Record<string, FileClaim>;
locks: Record<string, LockEntry[]>;
meta: {
updatedAt: string;
version: number;
};
}
/**
* Content written into the coordination lock file so stale locks can be
* detected by checking whether the owning PID is still alive.
*/
interface CoordLockContent {
owner: string;
pid: number;
hostname: string;
acquiredAt: string;
}
// ===================================================================
// SECTION 1 Atomic file-operation utilities
// ===================================================================
/**
* Write `data` to `filePath` atomically using the write-to-temp-then-rename
* pattern.
*
* 1. Serialise `data` as pretty-printed JSON.
* 2. Write to a temporary file (`filePath + ".tmp"`).
* 3. Atomically rename the temp file over the target path.
*
* On POSIX filesystems `rename()` is atomic when source and destination
* reside on the same mount point, so concurrent readers either see the
* old content or the new content — never a partial/corrupt file.
*
* @param filePath - Destination file path.
* @param data - Data to serialise and write. Passed through
* `JSON.stringify(data, null, 2)`.
*/
export async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tmpPath = filePath + TMP_SUFFIX;
const json = JSON.stringify(data, null, 2);
await fsPromises.writeFile(tmpPath, json, "utf-8");
await fsPromises.rename(tmpPath, filePath);
}
/**
* Read and deserialise a JSON file.
*
* Returns `null` when the file does not exist so callers can handle the
* first-write / empty-store case without catching an exception.
*
* @param filePath - Path to the JSON file to read.
* @returns The deserialised value, or `null` if the file is missing.
*/
export async function atomicReadJson<T = unknown>(filePath: string): Promise<T | null> {
try {
const raw = await fsPromises.readFile(filePath, "utf-8");
return JSON.parse(raw) as T;
} catch (err: unknown) {
if (err && typeof err === "object" && "code" in err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === "ENOENT") return null;
}
throw err;
}
}
/**
* Synchronous check for whether a file exists.
*
* A thin wrapper around `fs.existsSync` that is safe to call from
* constructors or sync contexts.
*/
export function fileExists(filePath: string): boolean {
return fs.existsSync(filePath);
}
// ===================================================================
// SECTION 2 Cross-process coordination
// ===================================================================
/**
* Try to acquire an advisory file lock using `O_CREAT | O_EXCL`.
*
* The lock file stores a small JSON payload with the owner id, PID, and
* hostname. If the file already exists (`EEXIST`) the function checks
* whether the lock is **stale** — i.e. the PID that created it is no
* longer running. Stale locks are removed automatically and the
* acquisition is retried.
*
* @param lockFilePath - Absolute path to the coordination lock file.
* @param ownerId - A short string identifying the caller (e.g. session
* ID or process label).
* @param maxWaitMs - Maximum time to wait before giving up (default 5 s).
* @returns `true` if the lock was acquired, `false` on timeout.
*/
export async function acquireLockFile(
lockFilePath: string,
ownerId: string,
maxWaitMs: number = DEFAULT_COORD_WAIT_MS,
): Promise<boolean> {
const start = Date.now();
const content: CoordLockContent = {
owner: ownerId,
pid: process.pid,
hostname: hostname(),
acquiredAt: new Date().toISOString(),
};
const contentStr = JSON.stringify(content);
while (Date.now() - start < maxWaitMs) {
try {
const fd = await fsPromises.open(
lockFilePath,
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
0o644,
);
await fd.writeFile(contentStr, "utf-8");
await fd.close();
return true;
} catch (err: unknown) {
if (err && typeof err === "object" && "code" in err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === "EEXIST") {
// Check if the existing lock is stale
if (await isLockFileStale(lockFilePath)) {
await fsPromises.unlink(lockFilePath).catch(() => {});
continue;
}
// Wait before retrying
await sleep(COORD_RETRY_INTERVAL_MS);
continue;
}
}
throw err;
}
}
return false; // timed out
}
/**
* Release a coordination lock file by deleting it.
*
* No error is thrown if the file has already been removed (e.g. by a
* crash-recovery mechanism).
*
* @param lockFilePath - Absolute path to the coordination lock file.
*/
export async function releaseLockFile(lockFilePath: string): Promise<void> {
try {
await fsPromises.unlink(lockFilePath);
} catch {
// Ignore — file may already be gone
}
}
/**
* Acquire a coordination lock, execute `fn`, and release the lock.
*
* @param lockFilePath - Absolute path to the coordination lock file.
* @param ownerId - Short identifier for the caller.
* @param fn - Async function to run while holding the lock.
* @param maxWaitMs - Maximum time to wait for the lock (default 5 s).
* @returns The return value of `fn`.
*/
export async function withLockFile<T>(
lockFilePath: string,
ownerId: string,
fn: () => Promise<T>,
maxWaitMs?: number,
): Promise<T> {
const acquired = await acquireLockFile(lockFilePath, ownerId, maxWaitMs);
if (!acquired) {
throw new Error(
`[lock-manager] Failed to acquire coordination lock "${lockFilePath}" ` +
`after ${maxWaitMs ?? DEFAULT_COORD_WAIT_MS}ms (owner: ${ownerId})`,
);
}
try {
return await fn();
} finally {
await releaseLockFile(lockFilePath);
}
}
/**
* Determine whether a lock file is stale by checking if the owning process
* is still alive.
*
* Reads the JSON payload from the lock file and calls `process.kill(pid, 0)`,
* which is a POSIX no-op that only checks whether the process exists.
*
* @param lockFilePath - Path to the lock file.
* @returns `true` if the lock file does not exist, cannot be parsed, or the
* PID that created it is no longer running.
*/
async function isLockFileStale(lockFilePath: string): Promise<boolean> {
try {
const raw = await fsPromises.readFile(lockFilePath, "utf-8");
const data: CoordLockContent = JSON.parse(raw);
try {
// Signal 0 tests whether the process exists without sending a signal.
process.kill(data.pid, 0);
return false; // Process is alive
} catch {
return true; // ESRCH or EPERM → process is dead
}
} catch {
return true; // File missing or invalid
}
}
// ===================================================================
// SECTION 3 Hostname helper
// ===================================================================
/** Cached hostname (avoids repeated system calls). */
let _hostname: string | null = null;
function hostname(): string {
if (_hostname === null) {
try {
_hostname = osHostname();
} catch {
_hostname = "unknown";
}
}
return _hostname!;
}
// ===================================================================
// SECTION 4 Promise-based sleep
// ===================================================================
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ===================================================================
// SECTION 5 LockManager class
// ===================================================================
/**
* Statistics snapshot returned by {@link LockManager.getStats}.
*/
export interface LockManagerStats {
totalClaims: number;
activeClaims: number;
expiredClaims: number;
totalLockPaths: number;
lockDir: string;
registryExists: boolean;
}
/**
* Result returned by {@link LockManager.syncFromDisk}.
*/
export interface SyncFromDiskResult {
/** All claims loaded from the on-disk registry. */
diskClaims: FileClaim[];
/** Subset of `diskClaims` that are expired. */
expired: FileClaim[];
/** Whether the on-disk registry was newer than the in-memory snapshot. */
wasUpdated: boolean;
}
/**
* Result returned by {@link LockManager.cleanupExpired}.
*/
export interface CleanupResult {
/** Number of claims that were marked as expired. */
expiredCount: number;
/** The claims that were expired. */
expiredClaims: FileClaim[];
}
/**
* File-backed lock manager that persists the claim registry to disk and
* coordinates access across multiple processes.
*
* ## Thread / process safety
*
* Within a single Node.js process all operations are safe because JS is
* single-threaded. Across processes, the `coord.lock` file (created with
* `O_EXCL`) serialises read-modify-write cycles on `registry.json`.
*
* ## Crash safety
*
* - `registry.json` is always written via write-to-temp-then-rename, so a
* crash during writing leaves the previous version intact.
* - The coordination lock is released on process exit via a best-effort
* `beforeExit` handler. If a process crashes while holding the lock,
* the stale-lock detection in {@link acquireLockFile} cleans it up.
*/
export class LockManager {
/** Absolute path to the lock directory. */
private readonly lockDir: string;
/** Absolute path to the coordination lock file. */
private readonly coordLockPath: string;
/** Absolute path to the serialised registry file. */
private readonly registryPath: string;
/** Whether this instance currently owns the coordination lock. */
private hasCoordLock: boolean = false;
/** Identifier passed to `acquireCoordLock` (for diagnostics). */
private coordOwnerId: string | null = null;
// -----------------------------------------------------------------------
// Construction and initialisation
// -----------------------------------------------------------------------
/**
* @param lockDir - Absolute path to a directory that will hold lock state
* files. The directory is created on first use if it
* does not exist.
*/
constructor(lockDir: string) {
this.lockDir = lockDir;
this.coordLockPath = path.resolve(lockDir, COORD_LOCK_FILE);
this.registryPath = path.resolve(lockDir, REGISTRY_FILE);
// Best-effort release of the coordination lock on process exit.
// This is a safety net — in normal operation callers should use
// withCoordLock or manually acquire/release.
process.on("beforeExit", () => {
if (this.hasCoordLock) {
fs.unlink(this.coordLockPath, () => {});
this.hasCoordLock = false;
}
});
}
/**
* Ensure the lock directory exists on disk.
*
* Must be called once before any other method. Safe to call multiple
* times (idempotent).
*/
async init(): Promise<void> {
await fsPromises.mkdir(this.lockDir, { recursive: true });
}
// -----------------------------------------------------------------------
// Public helpers
// -----------------------------------------------------------------------
/**
* Resolve the absolute path to the registry file.
* Useful for external monitoring or diagnostics.
*/
getRegistryFilePath(): string {
return this.registryPath;
}
/**
* Resolve the absolute path to the coordination lock file.
*/
getCoordLockFilePath(): string {
return this.coordLockPath;
}
/**
* Return the configured lock directory.
*/
getLockDir(): string {
return this.lockDir;
}
// -----------------------------------------------------------------------
// Cross-process coordination
// -----------------------------------------------------------------------
/**
* Acquire the coordination lock for this LockManager instance.
*
* After a successful call the lock is held until {@link releaseCoordLock}
* is called. Use {@link withCoordLock} for the common acquire → work →
* release pattern.
*
* @param ownerId - Short identifier for the caller.
* @param maxWaitMs - Maximum time to wait (default 5 s).
* @returns `true` if the lock was acquired.
*/
async acquireCoordLock(
ownerId: string,
maxWaitMs: number = DEFAULT_COORD_WAIT_MS,
): Promise<boolean> {
if (this.hasCoordLock) return true; // Re-entrant within same instance
const ok = await acquireLockFile(this.coordLockPath, ownerId, maxWaitMs);
if (ok) {
this.hasCoordLock = true;
this.coordOwnerId = ownerId;
}
return ok;
}
/**
* Release the coordination lock if this instance holds it.
*/
async releaseCoordLock(): Promise<void> {
if (!this.hasCoordLock) return;
await releaseLockFile(this.coordLockPath);
this.hasCoordLock = false;
this.coordOwnerId = null;
}
/**
* Acquire the coordination lock, execute `fn`, and release the lock.
*
* @param ownerId - Short identifier for the caller.
* @param fn - Async function to run while holding the lock.
* @param maxWaitMs - Maximum time to wait for the lock.
* @returns The return value of `fn`.
*/
async withCoordLock<T>(
ownerId: string,
fn: () => Promise<T>,
maxWaitMs?: number,
): Promise<T> {
const acquired = await this.acquireCoordLock(ownerId, maxWaitMs);
if (!acquired) {
throw new Error(
`[lock-manager] Failed to acquire coordination lock after ` +
`${maxWaitMs ?? DEFAULT_COORD_WAIT_MS}ms (owner: ${ownerId})`,
);
}
try {
return await fn();
} finally {
await this.releaseCoordLock();
}
}
// -----------------------------------------------------------------------
// Registry persistence (full-store operations)
// -----------------------------------------------------------------------
/**
* Atomically persist the full registry (claims + locks) to disk.
*
* @param claims - Claims map keyed by claim ID.
* @param locks - Lock entries keyed by file path.
*/
async saveRegistry(
claims: Record<string, FileClaim>,
locks: Record<string, LockEntry[]>,
): Promise<void> {
const data: RegistryData = {
claims,
locks,
meta: {
updatedAt: new Date().toISOString(),
version: 1,
},
};
await atomicWriteJson(this.registryPath, data);
}
/**
* Load the full registry from disk.
*
* Returns an empty registry `{ claims: {}, locks: {} }` when the file
* does not exist yet (first run).
*/
async loadRegistry(): Promise<{
claims: Record<string, FileClaim>;
locks: Record<string, LockEntry[]>;
}> {
const data = await atomicReadJson<RegistryData>(this.registryPath);
if (!data) {
return { claims: {}, locks: {} };
}
return {
claims: data.claims ?? {},
locks: data.locks ?? {},
};
}
// -----------------------------------------------------------------------
// Individual claim operations
// -----------------------------------------------------------------------
/**
* Persist a single claim to the registry.
*
* If a claim with the same `id` already exists it is overwritten.
* Uses the coordination lock to prevent races with other processes.
*
* @param claim - The claim to save.
*/
async saveClaim(claim: FileClaim): Promise<void> {
await this.withCoordLock(`save-claim`, async () => {
const registry = await this.loadRegistry();
registry.claims[claim.id] = claim;
await this.saveRegistry(registry.claims, registry.locks);
});
}
/**
* Load a single claim by its ID from the persisted registry.
*
* @param claimId - The unique claim identifier.
* @returns The claim, or `undefined` if not found.
*/
async loadClaim(claimId: string): Promise<FileClaim | undefined> {
const registry = await this.loadRegistry();
return registry.claims[claimId];
}
/**
* Delete a claim and its associated lock entries from the registry.
*
* @param claimId - The claim ID to remove.
* @returns `true` if the claim existed and was deleted, `false` otherwise.
*/
async deleteClaim(claimId: string): Promise<boolean> {
return this.withCoordLock(`delete-claim`, async () => {
const registry = await this.loadRegistry();
if (!registry.claims[claimId]) return false;
delete registry.claims[claimId];
// Remove any lock entries that reference this claim
for (const [lockPath, entries] of Object.entries(registry.locks)) {
const filtered = entries.filter((e) => e.claimId !== claimId);
if (filtered.length === 0) {
delete registry.locks[lockPath];
} else {
registry.locks[lockPath] = filtered;
}
}
await this.saveRegistry(registry.claims, registry.locks);
return true;
});
}
/**
* Return all claims currently stored in the persisted registry.
*/
async loadAllClaims(): Promise<FileClaim[]> {
const registry = await this.loadRegistry();
return Object.values(registry.claims);
}
/**
* Return all claims with a specific status.
*
* @param status - Status to filter by.
*/
async loadClaimsByStatus(status: ClaimStatus): Promise<FileClaim[]> {
const registry = await this.loadRegistry();
return Object.values(registry.claims).filter((c) => c.status === status);
}
/**
* Return all claims targeting a specific file path.
*
* @param filePath - File path to filter by.
*/
async loadClaimsByPath(filePath: string): Promise<FileClaim[]> {
const registry = await this.loadRegistry();
return Object.values(registry.claims).filter((c) => c.path === filePath);
}
// -----------------------------------------------------------------------
// Individual lock-entry operations
// -----------------------------------------------------------------------
/**
* Persist a single lock entry.
*
* If an entry with the same `claimId` already exists under the same
* `path`, it is replaced. Otherwise the entry is appended.
*
* @param entry - The lock entry to save.
*/
async saveLockEntry(entry: LockEntry): Promise<void> {
await this.withCoordLock(`save-lock-entry`, async () => {
const registry = await this.loadRegistry();
const entries = registry.locks[entry.path] ?? [];
const idx = entries.findIndex((e) => e.claimId === entry.claimId);
if (idx >= 0) {
entries[idx] = entry;
} else {
entries.push(entry);
}
registry.locks[entry.path] = entries;
await this.saveRegistry(registry.claims, registry.locks);
});
}
/**
* Remove a lock entry by claim ID and path.
*
* @param claimId - The claim ID whose lock entry to remove.
* @param lockPath - The file path the lock entry targets.
*/
async removeLockEntry(claimId: string, lockPath: string): Promise<void> {
await this.withCoordLock(`remove-lock-entry`, async () => {
const registry = await this.loadRegistry();
const entries = registry.locks[lockPath] ?? [];
const filtered = entries.filter((e) => e.claimId !== claimId);
if (filtered.length === 0) {
delete registry.locks[lockPath];
} else {
registry.locks[lockPath] = filtered;
}
await this.saveRegistry(registry.claims, registry.locks);
});
}
/**
* Load lock entries for a specific file path.
*
* @param lockPath - The file path whose lock entries to retrieve.
*/
async loadLockEntries(lockPath: string): Promise<LockEntry[]> {
const registry = await this.loadRegistry();
return registry.locks[lockPath] ?? [];
}
/**
* Load all lock entries, keyed by file path.
*/
async loadAllLockEntries(): Promise<Record<string, LockEntry[]>> {
const registry = await this.loadRegistry();
return registry.locks;
}
// -----------------------------------------------------------------------
// TTL-based expiration
// -----------------------------------------------------------------------
/**
* Check whether a single claim is expired.
*
* A claim is considered expired when:
* - Its status is `"active"`
* - It has an `expiresAt` timestamp
* - The `expiresAt` timestamp is in the past (or exactly now)
*
* Claims without an `expiresAt` field never expire through this check
* (they rely on explicit release or the sweeper's idle TTL).
*
* @param claim - The claim to check.
*/
isExpired(claim: FileClaim): boolean {
if (claim.status !== "active") return false;
if (!claim.expiresAt) return false;
return new Date(claim.expiresAt).getTime() <= Date.now();
}
/**
* Find all expired claims in the persisted registry without modifying it.
*/
async findExpired(): Promise<FileClaim[]> {
const registry = await this.loadRegistry();
return Object.values(registry.claims).filter((c) => this.isExpired(c));
}
/**
* Scan the persisted registry and **mark** all expired claims as `expired`,
* removing their lock entries.
*
* This is the file-backed equivalent of the in-memory `sweepExpiredClaims`
* function in `index.ts`. It uses the coordination lock so that only one
* process runs cleanup at a time.
*
* @returns A result with the count and list of claims that were expired.
*/
async cleanupExpired(): Promise<CleanupResult> {
return this.withCoordLock("cleanup-expired", async () => {
const registry = await this.loadRegistry();
const now = new Date().toISOString();
const expired: FileClaim[] = [];
// Identify expired claims
for (const [id, claim] of Object.entries(registry.claims)) {
if (this.isExpired(claim)) {
expired.push(claim);
// Update status
registry.claims[id] = {
...claim,
status: "expired",
updatedAt: now,
};
}
}
// Remove lock entries for expired claims
const expiredIds = new Set(expired.map((c) => c.id));
for (const [lockPath, entries] of Object.entries(registry.locks)) {
const filtered = entries.filter((e) => !expiredIds.has(e.claimId));
if (filtered.length === 0) {
delete registry.locks[lockPath];
} else {
registry.locks[lockPath] = filtered;
}
}
await this.saveRegistry(registry.claims, registry.locks);
return { expiredCount: expired.length, expiredClaims: expired };
});
}
/**
* Remove all claims and lock entries that are older than `maxAgeMs`
* regardless of their `expiresAt` field.
*
* This is a hard-age-based cleanup (not TTL-based) useful for sweeping
* orphaned entries that never had an `expiresAt` set.
*
* @param maxAgeMs - Maximum age in milliseconds. Claims whose
* `createdAt` is older than this threshold are removed entirely.
* @returns The number of claims removed.
*/
async sweepOlderThan(maxAgeMs: number): Promise<number> {
return this.withCoordLock("sweep-older-than", async () => {
const registry = await this.loadRegistry();
const cutoff = Date.now() - maxAgeMs;
const toRemove: string[] = [];
for (const [id, claim] of Object.entries(registry.claims)) {
if (new Date(claim.createdAt).getTime() < cutoff) {
toRemove.push(id);
}
}
for (const id of toRemove) {
delete registry.claims[id];
for (const [lockPath, entries] of Object.entries(registry.locks)) {
const filtered = entries.filter((e) => e.claimId !== id);
if (filtered.length === 0) {
delete registry.locks[lockPath];
} else {
registry.locks[lockPath] = filtered;
}
}
}
if (toRemove.length > 0) {
await this.saveRegistry(registry.claims, registry.locks);
}
return toRemove.length;
});
}
// -----------------------------------------------------------------------
// Cross-process synchronisation
// -----------------------------------------------------------------------
/**
* Load the current state from disk and return it alongside metadata
* about expired entries and staleness.
*
* Unlike {@link cleanupExpired} this method is read-only — it does not
* modify the persisted state.
*/
async syncFromDisk(): Promise<SyncFromDiskResult> {
const registry = await this.loadRegistry();
const diskClaims = Object.values(registry.claims);
const expired = diskClaims.filter((c) => this.isExpired(c));
return {
diskClaims,
expired,
wasUpdated: diskClaims.length > 0,
};
}
/**
* Atomically replace the entire on-disk registry with the provided
* in-memory state.
*
* Use this when the in-memory registry (in `index.ts`) has accumulated
* changes and you want a clean write-through to disk.
*
* @param claims - Full claims map.
* @param locks - Full locks map keyed by path.
*/
async syncToDisk(
claims: Record<string, FileClaim>,
locks: Record<string, LockEntry[]>,
): Promise<void> {
await this.withCoordLock("sync-to-disk", async () => {
await this.saveRegistry(claims, locks);
});
}
// -----------------------------------------------------------------------
// Merge helpers
// -----------------------------------------------------------------------
/**
* Merge claims from disk into an in-memory claims map.
*
* In-memory claims take precedence on ID collision (the in-memory value
* wins). Disk-only claims are added to the result.
*
* @param memoryClaims - Current in-memory claims map.
* @returns A merged claims map combining disk and in-memory state.
*/
async mergeFromDisk(
memoryClaims: Record<string, FileClaim>,
): Promise<Record<string, FileClaim>> {
const registry = await this.loadRegistry();
const merged = { ...registry.claims, ...memoryClaims };
return merged;
}
// -----------------------------------------------------------------------
// Statistics
// -----------------------------------------------------------------------
/**
* Gather statistics about the current persisted registry state.
*/
async getStats(): Promise<LockManagerStats> {
const registry = await this.loadRegistry();
const allClaims = Object.values(registry.claims);
return {
totalClaims: allClaims.length,
activeClaims: allClaims.filter((c) => c.status === "active").length,
expiredClaims: allClaims.filter((c) => c.status === "expired").length,
totalLockPaths: Object.keys(registry.locks).length,
lockDir: this.lockDir,
registryExists: fs.existsSync(this.registryPath),
};
}
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
/**
* Release the coordination lock if held and clean up resources.
*
* Call this when the extension shuts down to ensure the lock file
* is not left behind.
*/
async destroy(): Promise<void> {
await this.releaseCoordLock();
}
}
// ===================================================================
// SECTION 6 Convenience factory
// ===================================================================
/**
* Create a {@link LockManager} configured from the current
* `FileClaimingConfig`.
*
* The lock directory defaults to `config.lockDir` (from `config.ts`).
* Callers can override it by passing an explicit `lockDir` argument.
*
* @param lockDir - Optional explicit lock directory. Falls back to
* the configured `lockDir` from the extension config.
*/
export async function createLockManager(lockDir?: string): Promise<LockManager> {
const { getConfig } = await import("./config");
const config = getConfig();
const dir = lockDir ?? config.lockDir;
const mgr = new LockManager(dir);
await mgr.init();
return mgr;
}

270
src/lock-types.ts Normal file
View File

@@ -0,0 +1,270 @@
/**
* lock-types.ts — TypeScript type definitions for file claims and locks.
*
* This module defines the core data structures for the File Claiming
* extension. A "claim" represents an intent to read, modify, or exclusively
* own a file path. The registry tracks all active claims so downstream
* tools and events can detect conflicts, enforce ordering, and provide
* visibility into concurrent file access.
*
* @module file-claiming/lock-types
*/
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
/**
* The type of lock a claim holds on a file path.
*
* - `read` — Shared read access. Multiple read claims may coexist.
* - `write` — Exclusive write access. Only one write claim per path.
* - `exclusive` — Full exclusive access. No other claim of any type allowed.
*/
export type PathLockType = "read" | "write" | "exclusive";
/**
* The lifecycle status of a claim.
*
* - `pending` — Claim has been requested but not yet granted.
* - `active` — Claim is currently held and active.
* - `released` — Claim has been voluntarily released.
* - `conflicted`— Claim could not be granted due to a conflict.
* - `expired` — Claim timed out or was invalidated.
*/
export type ClaimStatus = "pending" | "active" | "released" | "conflicted" | "expired";
/**
* Severity level for conflict resolution.
*/
export type ConflictSeverity = "info" | "warning" | "error";
// ---------------------------------------------------------------------------
// Core interfaces
// ---------------------------------------------------------------------------
/**
* Metadata identifying the entity that owns a claim.
*
* @example
* ```ts
* const owner: ClaimOwner = {
* type: "tool",
* id: "my_tool",
* sessionId: "session-abc123",
* };
* ```
*/
export interface ClaimOwner {
/** Origin type: a registered tool, the current agent, or an extension. */
type: "tool" | "agent" | "extension";
/** Unique identifier within the owner type (e.g. tool name, extension ID). */
id: string;
/** Optional session identifier for scoping claims to a session lifetime. */
sessionId?: string;
}
/**
* A single claim on a file path.
*
* Claims represent an intent to access a file and are tracked in the
* {@link ClaimRegistry}. Tools and extensions create claims before
* operating on files so the registry can detect conflicts with other
* concurrent operations.
*/
export interface FileClaim {
/** Globally unique claim identifier (e.g. UUID v4). */
id: string;
/** Absolute or workspace-relative file path this claim targets. */
path: string;
/** The type of lock being claimed. */
lockType: PathLockType;
/** Current lifecycle status of the claim. */
status: ClaimStatus;
/** Owner responsible for this claim. */
owner: ClaimOwner;
/** ISO-8601 timestamp when the claim was created. */
createdAt: string;
/** ISO-8601 timestamp when the claim was last updated. */
updatedAt: string;
/** Optional ISO-8601 timestamp after which the claim auto-expires. */
expiresAt?: string;
/** Optional human-readable reason for the claim. */
reason?: string;
}
/**
* Describes a conflict between two or more claims on the same path.
*/
export interface ClaimConflict {
/** The file path where the conflict occurred. */
path: string;
/** Severity of the conflict. */
severity: ConflictSeverity;
/** The claim that is being blocked or challenged. */
blockedClaim: FileClaim;
/** Claims that are blocking {@link blockedClaim}. */
blockingClaims: FileClaim[];
/** Human-readable explanation of the conflict. */
message: string;
}
/**
* Result of attempting to acquire a claim.
*/
export interface ClaimResult {
/** Whether the claim was successfully acquired. */
success: boolean;
/** The acquired claim (if successful) or the attempted claim (if rejected). */
claim: FileClaim;
/** Conflict details when `success` is `false`. */
conflict?: ClaimConflict;
}
/**
* A resolved lock entry stored in the registry for a specific path.
*
* Unlike {@link FileClaim} which is a request/agreement, a `LockEntry`
* is the bookkeeping record the registry uses to track active locks.
*/
export interface LockEntry {
/** The file path this lock applies to. */
path: string;
/** The type of lock held. */
lockType: PathLockType;
/** The claim ID that established this lock. */
claimId: string;
/** The owner holding the lock. */
owner: ClaimOwner;
/** ISO-8601 timestamp when the lock was acquired. */
acquiredAt: string;
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
/**
* The central registry holding all active claims and locks.
*
* The registry is the single source of truth for which files are currently
* claimed. Extensions and tools query it before operating on files and
* update it when claims are created, released, or modified.
*/
export interface ClaimRegistry {
/**
* All claims currently tracked by the registry (any status).
* Indexed by claim ID for O(1) lookups.
*/
claims: Record<string, FileClaim>;
/**
* Active locks grouped by file path.
* Each entry maps a path to the current lock(s) on that path.
* Read-locks may have multiple entries; write/exclusive locks will
* have at most one.
*/
locks: Record<string, LockEntry[]>;
/**
* Returns all active claims (status === "active") for a given path.
*/
getActiveClaims(path: string): FileClaim[];
/**
* Returns the lock entries currently held on a given path.
*/
getLocks(path: string): LockEntry[];
/**
* Checks whether a proposed claim would conflict with existing locks.
*
* @param path The file path to check.
* @param lockType The lock type being requested.
* @param owner The owner requesting the claim (excluded from conflict
* detection so the same owner can hold multiple read claims).
* @returns The first conflict found, or `undefined` if the path is clear.
*/
checkConflict(path: string, lockType: PathLockType, owner: ClaimOwner): ClaimConflict | undefined;
/**
* Attempts to acquire a claim and register it.
*
* @param claim The claim to acquire (must have a unique `id`).
* @returns A result indicating success or failure with conflict details.
*/
acquire(claim: FileClaim): ClaimResult;
/**
* Releases a claim by its ID, removing its lock entries.
*
* @param claimId The ID of the claim to release.
* @returns `true` if the claim was found and released, `false` otherwise.
*/
release(claimId: string): boolean;
/**
* Releases all claims owned by a specific owner (e.g. on tool completion).
*/
releaseAllByOwner(owner: ClaimOwner): void;
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
/**
* Names of events emitted by the claim registry.
*/
export type ClaimEventType =
| "claim:acquired"
| "claim:released"
| "claim:conflicted"
| "claim:expired"
| "claim:status_changed";
/**
* Payload carried by claim registry events.
*/
export interface ClaimEvent {
/** The type of event that occurred. */
type: ClaimEventType;
/** The claim involved in the event. */
claim: FileClaim;
/** Optional conflict information (present on `claim:conflicted`). */
conflict?: ClaimConflict;
/** ISO-8601 timestamp of the event. */
timestamp: string;
}
// ---------------------------------------------------------------------------
// Options & configuration
// ---------------------------------------------------------------------------
/**
* Configuration options for the File Claiming extension.
*/
export interface FileClaimingOptions {
/**
* Default TTL (in milliseconds) for claims that do not specify
* an explicit `expiresAt`. Claims exceeding this age are eligible
* for automatic expiry. Set to `0` to disable auto-expiry.
*
* @default 30_000 (30 seconds)
*/
defaultClaimTTL: number;
/**
* Interval (in milliseconds) at which the expiry sweeper runs.
*
* @default 5_000 (5 seconds)
*/
sweepInterval: number;
/**
* Whether to emit `claim:conflicted` events as UI notifications.
*
* @default true
*/
notifyOnConflict: boolean;
}

317
src/notifications.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* notifications.ts — Notification system for lock events.
*
* Listens to lock events and sends notifications to the user.
* Supports different notification types and severity levels.
*
* @module file-claiming/notifications
*/
import type { EventBus } from "@earendil-works/pi-coding-agent";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import type {
ClaimEventType,
ClaimEvent,
ClaimConflict,
FileClaim,
LockEntry,
} from "./lock-types";
import { getConfig } from "./config";
import { createDiagnosticEvent, createDiagnosticEvent as createDiagEvent } from "./diagnostics";
import type { DiagnosticEventType } from "./diagnostics";
// ---------------------------------------------------------------------------
// Notification types
// ---------------------------------------------------------------------------
/**
* Types of lock notifications.
*/
export type LockNotificationType =
| "claim:acquired"
| "claim:released"
| "claim:conflicted"
| "claim:expired"
| "diagnostic:added"
| "diagnostic:removed"
| "diagnostic:updated";
/**
* Severity level for notifications.
*/
export type NotificationSeverity = "info" | "warning" | "error";
/**
* A notification message for the user.
*/
export interface LockNotification {
/** Type of the notification. */
type: LockNotificationType;
/** Severity level. */
severity: NotificationSeverity;
/** Short title for the notification. */
title: string;
/** Full message body. */
message: string;
/** ISO-8601 timestamp. */
timestamp: string;
/** Optional claim data. */
claim?: FileClaim;
/** Optional conflict data. */
conflict?: ClaimConflict;
}
// ---------------------------------------------------------------------------
// Notification builders
// ---------------------------------------------------------------------------
/**
* Build a notification from a claim event.
*/
export function claimEventToNotification(event: ClaimEvent): LockNotification {
const config = getConfig();
const claim = event.claim;
switch (event.type) {
case "claim:acquired":
return {
type: "claim:acquired",
severity: "info",
title: `Lock Acquired: ${claim.lockType}`,
message: `Claimed "${claim.path}" with ${claim.lockType} lock by ${claim.owner.type} (${claim.owner.id})${claim.reason ? `${claim.reason}` : ""}${config.autoReleaseTTL > 0 ? `\nAuto-release: ${config.autoReleaseTTL}ms` : ""}`,
timestamp: event.timestamp,
claim,
};
case "claim:released":
return {
type: "claim:released",
severity: "info",
title: `Lock Released`,
message: `Released "${claim.path}" (${claim.lockType} lock) by ${claim.owner.type} (${claim.owner.id})`,
timestamp: event.timestamp,
claim,
};
case "claim:conflicted":
return {
type: "claim:conflicted",
severity: "warning",
title: `Lock Conflict`,
message: event.conflict
? `Cannot acquire "${claim.lockType}" lock on "${claim.path}": ${event.conflict.message}`
: `Conflict acquiring lock on "${claim.path}"`,
timestamp: event.timestamp,
claim,
conflict: event.conflict,
};
case "claim:expired":
return {
type: "claim:expired",
severity: "info",
title: `Lock Expired`,
message: `Auto-expired "${claim.path}" (${claim.lockType} lock) — TTL exceeded`,
timestamp: event.timestamp,
claim,
};
default:
return {
type: "claim:acquired",
severity: "info",
title: "Lock Event",
message: `Event: ${event.type} on "${claim.path}"`,
timestamp: event.timestamp,
claim,
};
}
}
/**
* Build a notification from a diagnostic event.
*/
export function diagnosticEventToNotification(
event: ReturnType<typeof createDiagEvent>,
): LockNotification {
switch (event.type) {
case "diagnostic:added":
return {
type: "diagnostic:added",
severity: event.diagnostic?.severity ?? "info",
title: "Lock Status",
message: event.diagnostic?.message ?? `Diagnostics added for "${event.uri}"`,
timestamp: event.timestamp,
};
case "diagnostic:removed":
return {
type: "diagnostic:removed",
severity: "info",
title: "Lock Status",
message: `Diagnostics removed for "${event.uri}"`,
timestamp: event.timestamp,
};
case "diagnostic:updated":
return {
type: "diagnostic:updated",
severity: event.diagnostic?.severity ?? "info",
title: "Lock Updated",
message: event.diagnostic?.message ?? `Diagnostics updated for "${event.uri}"`,
timestamp: event.timestamp,
};
default:
return {
type: "diagnostic:added",
severity: "info",
title: "Lock Status",
message: `Diagnostics refreshed for "${event.uri}"`,
timestamp: event.timestamp,
};
}
}
// ---------------------------------------------------------------------------
// Notification handler
// ---------------------------------------------------------------------------
/**
* Handler for lock events that sends notifications.
*/
export interface LockNotificationHandler {
/** Handle a lock event and send a notification. */
handleEvent(event: ClaimEvent): void;
/** Handle a diagnostic event. */
handleDiagnostic(event: ReturnType<typeof createDiagEvent>): void;
/** Get all pending notifications. */
getNotifications(): LockNotification[];
/** Clear all notifications. */
clearNotifications(): void;
}
/**
* Create a lock notification handler backed by the UI context.
*/
export function createLockNotificationHandler(
ui: ExtensionUIContext,
eventBus: EventBus,
): LockNotificationHandler {
const notifications: LockNotification[] = [];
// Subscribe to lock events on the event bus
const unsubLock = eventBus.on("claim:acquired", (data: unknown) => {
handleClaimEvent(data as ClaimEvent);
});
const unsubRelease = eventBus.on("claim:released", (data: unknown) => {
handleClaimEvent(data as ClaimEvent);
});
const unsubConflict = eventBus.on("claim:conflicted", (data: unknown) => {
handleClaimEvent(data as ClaimEvent);
});
const unsubExpired = eventBus.on("claim:expired", (data: unknown) => {
handleClaimEvent(data as ClaimEvent);
});
function handleClaimEvent(event: ClaimEvent) {
const notification = claimEventToNotification(event);
notifications.push(notification);
// Only show notifications if enabled in config
if (getConfig().showDiagnostics) {
const severity: NotificationSeverity = notification.severity;
ui.notify(notification.message, severity === "error" ? "error" : "info");
}
}
return {
handleEvent(event: ClaimEvent) {
const notification = claimEventToNotification(event);
notifications.push(notification);
if (getConfig().showDiagnostics) {
ui.notify(notification.message, notification.severity === "error" ? "error" : "info");
}
},
handleDiagnostic(event: ReturnType<typeof createDiagEvent>) {
const notification = diagnosticEventToNotification(event);
notifications.push(notification);
},
getNotifications() {
return [...notifications];
},
clearNotifications() {
notifications.length = 0;
},
};
}
// ---------------------------------------------------------------------------
// Notification formatting
// ---------------------------------------------------------------------------
/**
* Format a notification as a string for display.
*/
export function formatNotification(notification: LockNotification): string {
const icon =
notification.severity === "error"
? "❌"
: notification.severity === "warning"
? "⚠️"
: "";
return [
`${icon} ${notification.title}`,
notification.message,
notification.claim
? `\n Claim ID: ${notification.claim.id}`
: "",
notification.conflict
? `\n Conflict: ${notification.conflict.message}`
: "",
].join("\n");
}
/**
* Format all notifications as a summary.
*/
export function formatNotificationsSummary(notifications: LockNotification[]): string {
if (notifications.length === 0) {
return "No lock notifications.";
}
const lines = [
`🔔 Lock Notifications (${notifications.length})`,
"",
];
// Group by type
const grouped = new Map<string, LockNotification[]>();
for (const n of notifications) {
const existing = grouped.get(n.type) ?? [];
existing.push(n);
grouped.set(n.type, existing);
}
for (const [type, items] of grouped) {
const icon =
items[0].severity === "error"
? "❌"
: items[0].severity === "warning"
? "⚠️"
: "";
lines.push(`${icon} ${type}: ${items.length}`);
for (const item of items.slice(0, 3)) {
lines.push(` - ${item.title}`);
}
if (items.length > 3) {
lines.push(` ... and ${items.length - 3} more`);
}
}
return lines.join("\n");
}

173
src/system-prompt.ts Normal file
View File

@@ -0,0 +1,173 @@
/**
* system-prompt.ts — System prompt injection for lock claiming protocol.
*
* Injects lock claiming instructions into the agent's system prompt so that
* the agent knows how to acquire, hold, and release file claims during tool
* execution.
*
* @module file-claiming/system-prompt
*/
import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
import { getConfig } from "./config";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* Lock claiming protocol instructions that are injected into the system prompt.
*
* These instructions are appended to the default system prompt when the
* extension is active.
*/
const LOCK_CLAIMING_INSTRUCTIONS = '<file_claiming>\n' +
'## Lock Claiming Protocol\n' +
'\n' +
'You hold a file claiming system to prevent conflicts when working with files.\n' +
'This system tracks read, write, and exclusive claims on file paths.\n' +
'\n' +
'### Claim Types\n' +
'- **read** \u2014 Shared read access. Multiple tools can hold read claims simultaneously.\n' +
'- **write** \u2014 Exclusive write access. Only one write claim per file path.\n' +
'- **exclusive** \u2014 Full exclusive access. No other claim of any type allowed.\n' +
'\n' +
'### How Locks Work\n' +
'1. Before editing or writing to a file, check if it has an active claim.\n' +
'2. If a conflicting lock exists, release your claim first or wait for it to expire.\n' +
'3. When you claim a file, other tools may be blocked until you release.\n' +
'4. Claims have a TTL (time-to-live) and will auto-release if you forget.\n' +
'\n' +
'### Auto-Release Behavior\n' +
'- Claims are automatically released when your turn ends (unless disabled).\n' +
'- Default TTL: {autoReleaseTTL}ms. Claims expire after this time.\n' +
'- You can check active claims with the `file_claiming_list` tool.\n' +
'- You can check a specific file with the `file_claiming_check` tool.\n' +
'\n' +
'### Conflict Resolution\n' +
'- If you try to claim a file that is locked by another tool, you will receive a conflict notification.\n' +
'- Conflicting locks show the blocker\'s claim ID, type, and owner.\n' +
'- You can release conflicting claims with the `file_claiming_release` tool.\n' +
'\n' +
'### Best Practices\n' +
'- Claim files before editing to prevent conflicts.\n' +
'- Release claims after editing to unblock other tools.\n' +
'- Check for locks before starting large edits.\n' +
'- Use "write" lock for normal edits, "exclusive" for critical operations.\n' +
'\n' +
'### Releasing Claims\n' +
'- Claims are released automatically at turn end by default.\n' +
'- You can manually release with `file_claiming_release` if needed.\n' +
'- If a claim is blocking another tool, release it to unblock.\n' +
'</file_claiming>';
/**
* Build the lock claiming instructions section with current config values.
*/
export function buildLockClaimingInstructions(): string {
const config = getConfig();
return LOCK_CLAIMING_INSTRUCTIONS.replace(
"{autoReleaseTTL}",
String(config.autoReleaseTTL),
);
}
/**
* Build the guidelines section for lock claiming.
*
* These guidelines are appended to the default system prompt's guidelines
* section when the extension is active.
*/
export function buildLockClaimingGuidelines(): string[] {
const config = getConfig();
return [
'Use the `file_claiming_claim` tool to claim files before editing them.',
`Use the \`file_claiming_release\` tool to release a claim (TTL: ${config.autoReleaseTTL}ms).`,
`Use the \`file_claiming_list\` tool to see all active claims.`,
`Use the \`file_claiming_check\` tool to check a specific file's lock status.`,
config.releaseOnTurnEnd
? 'Claims are released automatically at the end of each turn.'
: 'Claims persist across turns; release them explicitly.',
];
}
/**
* Build the prompt snippet for the lock claiming tools.
*
* These one-line snippets appear in the "Available tools" section of
* the default system prompt.
*/
export function buildLockClaimingToolSnippets(): Record<string, string> {
return {
file_claiming_claim: "Claim a file with read/write/exclusive lock",
file_claiming_release: "Release a file claim by ID or path",
file_claiming_list: "List all active file claims",
file_claiming_check: "Check lock status for a specific file",
};
}
// ---------------------------------------------------------------------------
// System prompt injection
// ---------------------------------------------------------------------------
/**
* Inject lock claiming instructions into the system prompt.
*
* This function takes the options that will be used to build the system prompt
* and returns a new options object with lock claiming content added.
*
* @param options - The build options for the system prompt.
* @param enabled - Whether to inject lock claiming content (default: true).
* @returns A new options object with injected content.
*/
export function injectLockClaimingIntoPrompt(
options: BuildSystemPromptOptions,
enabled: boolean = true,
): BuildSystemPromptOptions {
if (!enabled) {
return options;
}
const config = getConfig();
if (!config.showDiagnostics) {
return options;
}
const instructions = buildLockClaimingInstructions();
const guidelines = buildLockClaimingGuidelines();
const snippets = buildLockClaimingToolSnippets();
return {
...options,
promptGuidelines: [...(options.promptGuidelines ?? []), ...guidelines],
toolSnippets: { ...options.toolSnippets, ...snippets },
appendSystemPrompt: options.appendSystemPrompt
? `${options.appendSystemPrompt}\n${instructions}`
: instructions,
};
}
// ---------------------------------------------------------------------------
// Hook into before_agent_start
// ---------------------------------------------------------------------------
/**
* Create a handler for the `before_agent_start` event that injects lock
* claiming instructions into the system prompt.
*
* This handler is designed to be registered with `pi.on("before_agent_start", ...)`.
*/
export function createBeforeAgentStartHandler(): (
event: { systemPrompt: string; systemPromptOptions: BuildSystemPromptOptions },
ctx: { getSystemPromptOptions(): BuildSystemPromptOptions },
) => Promise<{ systemPrompt?: string }> {
return async () => {
// This is called during system prompt construction.
// We inject by updating the options, which the runner will use.
const options = injectLockClaimingIntoPrompt(
{ cwd: "." },
getConfig().showDiagnostics,
);
return { systemPrompt: options.appendSystemPrompt ?? "" };
};
}

428
src/tools.ts Normal file
View File

@@ -0,0 +1,428 @@
/**
* tools.ts — Lock management tools registered with Pi.
*
* Provides four tools that the LLM can call during tool execution:
* - `file_claiming_claim` — Claim a file with a lock
* - `file_claiming_release` — Release a claim by ID or path
* - `file_claiming_list` — List all active claims
* - `file_claiming_check` — Check lock status for a file
*
* @module file-claiming/tools
*/
import { Type, type Static } from "typebox";
import type {
ToolDefinition,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { getClaimRegistry } from "../index";
import { getConfig } from "./config";
import {
claimToDiagnostic,
formatDiagnostics,
buildDiagnosticCollection,
} from "./diagnostics";
import type { ClaimOwner, PathLockType } from "./lock-types";
import {
acquireLock,
autoClaim,
isFileLocked,
getLockInfo,
isMutationTool,
shouldAutoClaim,
handleToolLock,
buildConflictMessage,
buildBlockingError,
getConcurrentAccess,
} from "./lock-acquisition";
/** Update the footer status with current claim count. */
function updateFooterStatus(ctx: ExtensionContext): void {
const registry = getClaimRegistry();
const activeCount = Object.values(registry.claims).filter(
(c) => c.status === "active",
).length;
if (ctx?.ui?.setStatus) {
ctx.ui.setStatus("file-claiming", `Claims: ${activeCount} active`);
}
}
/** Generate a unique claim ID using the Web Crypto API. */
function generateClaimId(): string {
return crypto.randomUUID();
}
// ---------------------------------------------------------------------------
// Helper to get the current owner
// ---------------------------------------------------------------------------
function getCurrentOwner(ctx: ExtensionContext): ClaimOwner {
const sessionId = ctx.sessionManager.getSessionFile();
return {
type: "agent",
id: "main",
sessionId,
};
}
// ---------------------------------------------------------------------------
// Tool schemas
// ---------------------------------------------------------------------------
const claimSchema = Type.Object({
path: Type.String({ description: "File path to claim" }),
lockType: Type.Union(
[Type.Literal("read"), Type.Literal("write"), Type.Literal("exclusive")],
{ description: "Type of lock to acquire" },
),
reason: Type.Optional(
Type.String({ description: "Optional reason for claiming" }),
),
});
const releaseSchema = Type.Object({
claimId: Type.Optional(
Type.String({ description: "Claim ID to release (if known)" }),
),
path: Type.Optional(
Type.String({ description: "File path to release claims on" }),
),
});
const listSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Optional path filter" })),
});
const checkSchema = Type.Object({
path: Type.String({ description: "File path to check" }),
lockType: Type.Optional(
Type.Union(
[Type.Literal("read"), Type.Literal("write"), Type.Literal("exclusive")],
{ description: "Lock type to check" },
),
),
});
// ---------------------------------------------------------------------------
// Tool implementations
// ---------------------------------------------------------------------------
/**
* Claim a file with a lock.
*
* This tool supports both manual claiming and auto-claim behavior.
* When called, it checks if the file is already locked and resolves
* conflicts automatically.
*/
export const fileClaimingClaimTool = defineTool({
name: "file_claiming_claim",
label: "Claim File",
description:
"Claim a file with a read, write, or exclusive lock. Supports auto-claim on first edit. " +
"Returns the claim ID, lock status, and auto-release time. " +
"Use this before editing files to prevent conflicts with other tools.",
promptSnippet: "Claim a file with read/write/exclusive lock",
parameters: claimSchema,
execute: async (
_toolCallId: string,
params: Static<typeof claimSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
ctx: ExtensionContext,
): Promise<
AgentToolResult<{ diagnostic: ReturnType<typeof claimToDiagnostic> }>
> => {
const registry = getClaimRegistry();
const config = getConfig();
const owner = getCurrentOwner(ctx);
// Use auto-claim if the file is not already claimed
const result = autoClaim({
path: params.path,
lockType: params.lockType,
owner,
autoReleaseTTL: config.autoReleaseTTL,
reason: params.reason,
});
if (result.success) {
const diagnostic = claimToDiagnostic(result.claim!, registry);
updateFooterStatus(ctx);
return {
content: [
{
type: "text" as const,
text:
`${result.message}\n` +
`Claim ID: ${result.claim!.id}\n` +
`Owner: ${result.claim!.owner.type} (${result.claim!.owner.id})\n` +
(result.claim!.expiresAt
? `Auto-release: ${result.claim!.expiresAt}`
: ""),
},
],
details: { diagnostic },
};
}
return {
content: [
{
type: "text" as const,
text: `Failed to claim "${params.path}":\n${result.message}`,
},
],
details: { diagnostic: claimToDiagnostic(result.claim!, registry) },
};
},
});
/**
* Release a claim by ID or path.
*
* Also supports bulk release and expired lock cleanup.
*/
export const fileClaimingReleaseTool = defineTool({
name: "file_claiming_release",
label: "Release Claim",
description:
"Release a file claim. Provide either a claim ID or a file path. " +
"If a path is given, all claims on that path are released. " +
"If a claim ID is given, only that specific claim is released. " +
"Use path='*' to release all claims.",
promptSnippet: "Release a file claim by ID or path",
parameters: releaseSchema,
execute: async (
_toolCallId: string,
params: Static<typeof releaseSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
ctx: ExtensionContext,
): Promise<AgentToolResult<{ released: string[] }>> => {
const registry = getClaimRegistry();
const released: string[] = [];
// Special case: release all claims
if (params.path === "*" && !params.claimId) {
const allClaims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
for (const claim of allClaims) {
registry.release(claim.id);
released.push(claim.id);
}
updateFooterStatus(ctx);
return {
content: [
{
type: "text" as const,
text:
released.length > 0
? `Released ${released.length} claim(s):\n${released.map((id) => ` - ${id}`).join("\n")}`
: "No claims to release.",
},
],
details: { released },
};
}
if (params.claimId) {
const success = registry.release(params.claimId);
if (success) {
released.push(params.claimId);
}
}
if (params.path) {
const claims = registry.getActiveClaims(params.path);
for (const claim of claims) {
registry.release(claim.id);
released.push(claim.id);
}
}
// If neither was provided, release all active claims
if (!params.claimId && !params.path) {
const allClaims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
for (const claim of allClaims) {
registry.release(claim.id);
released.push(claim.id);
}
}
return {
content: [
{
type: "text" as const,
text:
released.length > 0
? `Released ${released.length} claim(s):\n${released.map((id) => ` - ${id}`).join("\n")}`
: "No claims to release.",
},
],
details: { released },
};
},
});
/**
* List all active claims.
*/
export const fileClaimingListTool = defineTool({
name: "file_claiming_list",
label: "List Claims",
description:
"List all active file claims with details about lock type, owner, and " +
"auto-release time. Optionally filter by file path.",
promptSnippet: "List all active file claims",
parameters: listSchema,
execute: async (
_toolCallId: string,
params: Static<typeof listSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
_ctx: ExtensionContext,
): Promise<
AgentToolResult<{
collection: ReturnType<typeof buildDiagnosticCollection>;
}>
> => {
const registry = getClaimRegistry();
const collection = buildDiagnosticCollection(registry);
// Filter by path if provided
let items = Array.from(collection.diagnostics.entries());
if (params.path) {
items = items.filter(([uri]) => uri === params.path);
}
const formatted = formatDiagnostics(collection);
const filtered =
items.length > 0
? `Filtered to "${params.path}":\n${formatDiagnostics(collection)}`
: formatted;
return {
content: [
{
type: "text" as const,
text:
items.length > 0
? filtered
: `No active claims${params.path ? ` for "${params.path}"` : ""}.`,
},
],
details: { collection },
};
},
});
/**
* Check lock status for a specific file.
*
* Shows detailed lock information including concurrent access,
* auto-release time, and lock compatibility.
*/
export const fileClaimingCheckTool = defineTool({
name: "file_claiming_check",
label: "Check Lock",
description:
"Check the lock status for a specific file. Shows active claims, " +
"lock type, owner, auto-release time, concurrent access, and " +
"whether the file is free for a given lock type.",
promptSnippet: "Check lock status for a specific file",
parameters: checkSchema,
execute: async (
_toolCallId: string,
params: Static<typeof checkSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
_ctx: ExtensionContext,
): Promise<AgentToolResult<{ checked: boolean }>> => {
const registry = getClaimRegistry();
const config = getConfig();
const lockType = params.lockType ?? "read";
const owner = getCurrentOwner(_ctx);
// Use the lock acquisition module for detailed info
const info = getLockInfo(params.path);
const conflict = registry.checkConflict(params.path, lockType, owner);
const lines: string[] = [`Lock status for "${params.path}":`];
if (info.locked) {
lines.push(
` 🔒 LOCKED (${info.lockType} lock)`,
` Holder: ${info.owner.type} (${info.owner.id})`,
` Auto-release: ${info.autoReleaseIn}`,
);
for (const claim of info.claims) {
const autoRelease = claim.expiresAt
? new Date(claim.expiresAt).toISOString()
: "N/A";
lines.push(
` - ${claim.lockType} by ${claim.owner.type}(${claim.owner.id}) ` +
`until ${autoRelease}${claim.reason ? ` [${claim.reason}]` : ""}`,
);
}
// Show concurrent access
const concurrent = getConcurrentAccess(
params.path,
owner.sessionId ?? "",
);
if (concurrent.length > 0) {
lines.push(
` Concurrent access: ${concurrent.length} claim(s) from other sessions`,
);
}
} else {
lines.push(
` ✅ FREE — no active locks`,
` Can acquire ${lockType} lock.`,
);
}
if (conflict) {
lines.push(` ⚠️ Conflict: ${conflict.message}`);
}
return {
content: [
{
type: "text" as const,
text: lines.join("\n"),
},
],
details: { checked: true },
};
},
});
// ---------------------------------------------------------------------------
// Tool registration
// ---------------------------------------------------------------------------
/**
* Register all lock management tools with the Pi extension API.
*
* Also registers the auto-claim tool that automatically claims files
* on first edit attempt.
*
* @param pi - The extension API instance.
*/
export function registerLockTools(
pi: Pick<ExtensionContext, "ui"> & {
registerTool: typeof import("@earendil-works/pi-coding-agent").defineTool;
},
): void {
pi.registerTool(fileClaimingClaimTool);
pi.registerTool(fileClaimingReleaseTool);
pi.registerTool(fileClaimingListTool);
pi.registerTool(fileClaimingCheckTool);
}

335
src/user-interaction.ts Normal file
View File

@@ -0,0 +1,335 @@
/**
* user-interaction.ts — User interaction components for lock queries.
*
* Provides interactive components that allow users to query and manage
* file locks through the TUI.
*
* @module file-claiming/user-interaction
*/
import type {
ExtensionUIContext,
ExtensionContext,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import type { ClaimRegistry } from "./lock-types";
import { getClaimRegistry, resetRegistry } from "../index";
import { getConfig } from "./config";
import { formatDiagnostics, buildDiagnosticCollection } from "./diagnostics";
import { formatNotificationsSummary } from "./notifications";
import type {
LockNotification,
LockNotificationHandler,
} from "./notifications";
// ---------------------------------------------------------------------------
// Lock query components
// ---------------------------------------------------------------------------
/**
* Show an interactive lock query dialog.
*
* Displays a list of active claims and allows the user to select one
* to view details or release.
*/
export async function showLockQueryDialog(
ui: ExtensionUIContext,
registry: ClaimRegistry,
): Promise<void> {
const collection = buildDiagnosticCollection(registry);
const formatted = formatDiagnostics(collection);
const result = await ui.select(
"File Claims",
[
"View all claims",
"Check a specific file",
"View notifications",
"Release all claims",
],
{ timeout: 30_000 },
);
if (!result) return;
switch (result) {
case "View all claims":
ui.notify(formatted, "info");
break;
case "Check a specific file": {
const path = await ui.input(
"File Path",
"Enter file path (e.g., /path/to/file.ts)",
{ timeout: 15_000 },
);
if (path) {
const claims = registry.getActiveClaims(path);
if (claims.length > 0) {
ui.notify(
`Active claims for "${path}":\n${claims.map((c) => ` - ${c.lockType} by ${c.owner.type} (${c.owner.id})`).join("\n")}`,
"info",
);
} else {
ui.notify(`No active claims for "${path}".`, "info");
}
}
break;
}
case "View notifications": {
// This would require access to the notification handler
// For now, show a placeholder
ui.notify(
"Viewing notifications... (use /file-claiming-notify to see recent events)",
"info",
);
break;
}
case "Release all claims": {
const confirmed = await ui.confirm(
"Release All Claims",
`Release all ${collection.count} active claims?`,
{ timeout: 10_000 },
);
if (confirmed) {
const claims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
for (const claim of claims) {
registry.release(claim.id);
}
ui.notify(`Released ${claims.length} claim(s).`, "info");
}
break;
}
}
}
// ---------------------------------------------------------------------------
// Lock status widget component
// ---------------------------------------------------------------------------
/**
* Create a lock status widget that shows active claims.
*
* This widget is updated periodically when claims change.
*/
export function createLockStatusWidget(
registry: ClaimRegistry,
): () => string[] {
return () => {
const config = getConfig();
const collection = buildDiagnosticCollection(registry);
const lines: string[] = [];
lines.push(`Claims: ${collection.count} active`);
if (collection.count > 0) {
lines.push("");
lines.push("Active locks:");
for (const [uri, items] of collection.diagnostics) {
const icon = items[0]?.severity === "error" ? "❌" : "";
lines.push(` ${icon} ${uri} (${items[0]?.lockType})`);
}
}
return lines;
};
}
// ---------------------------------------------------------------------------
// Lock command handler
// ---------------------------------------------------------------------------
/**
* Create a command handler for `/file-claiming-locks`.
*
* This command provides interactive lock management.
*/
export function createLockCommandHandler(
getRegistry: () => ClaimRegistry,
): (args: string, ctx: ExtensionCommandContext) => Promise<void> {
return async (args: string, ctx: ExtensionCommandContext) => {
const registry = getRegistry();
const trimmed = args.trim();
if (!trimmed) {
// Interactive mode: show dialog
await showLockQueryDialog(ctx.ui, registry);
return;
}
// Parse command arguments
const [action, ...rest] = trimmed.split(/\s+/);
switch (action) {
case "list": {
const path = rest[0];
const collection = path
? {
...buildDiagnosticCollection(registry),
diagnostics: new Map([
[
path,
buildDiagnosticCollection(registry).diagnostics.get(path) ??
[],
],
]),
}
: buildDiagnosticCollection(registry);
ctx.ui.notify(formatDiagnostics(collection), "info");
break;
}
case "check": {
const path = rest[0];
if (!path) {
ctx.ui.notify("Usage: /file-claiming-locks check <path>", "warning");
return;
}
const claims = registry.getActiveClaims(path);
if (claims.length > 0) {
ctx.ui.notify(
`Active claims for "${path}":\n${claims.map((c) => ` - ${c.lockType} by ${c.owner.type} (${c.owner.id})`).join("\n")}`,
"info",
);
} else {
ctx.ui.notify(`No active claims for "${path}".`, "info");
}
break;
}
case "release": {
const path = rest[0];
const claimId = rest[1];
if (path) {
const claims = registry.getActiveClaims(path);
for (const claim of claims) {
registry.release(claim.id);
}
ctx.ui.notify(
`Released ${claims.length} claim(s) on "${path}".`,
"info",
);
} else if (claimId) {
const success = registry.release(claimId);
ctx.ui.notify(
success
? `Released claim "${claimId}".`
: `Claim "${claimId}" not found.`,
success ? "info" : "error",
);
} else {
// Release all
const claims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
for (const claim of claims) {
registry.release(claim.id);
}
ctx.ui.notify(`Released ${claims.length} claim(s).`, "info");
}
break;
}
case "reset": {
resetRegistry();
ctx.ui.notify("Registry reset. All claims cleared.", "info");
break;
}
default:
ctx.ui.notify(
`Unknown action: "${action}".\n` +
"Usage:\n" +
" /file-claiming-locks — interactive mode\n" +
" /file-claiming-locks list [path] — list claims\n" +
" /file-claiming-locks check <path> — check a file\n" +
" /file-claiming-locks release [path] [id] — release claims\n" +
" /file-claiming-locks reset — reset registry",
"info",
);
}
};
}
// ---------------------------------------------------------------------------
// Notification command handler
// ---------------------------------------------------------------------------
/**
* Create a command handler for `/file-claiming-notify`.
*/
export function createNotifyCommandHandler(
getNotifications: () => LockNotification[],
): (args: string, ctx: ExtensionCommandContext) => Promise<void> {
return async (args: string, ctx: ExtensionCommandContext) => {
const notifications = getNotifications();
const trimmed = args.trim();
if (trimmed === "clear") {
ctx.ui.notify("Notifications cleared.", "info");
return;
}
ctx.ui.notify(formatNotificationsSummary(notifications), "info");
};
}
// ---------------------------------------------------------------------------
// State persistence
// ---------------------------------------------------------------------------
/**
* Persist lock state to the session for recovery after reload.
*/
export function persistLockState(pi: {
appendEntry: (type: string, data?: unknown) => void;
}): void {
const registry = getClaimRegistry();
const activeClaims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
pi.appendEntry("file-claiming-state", {
version: "1.0.0",
activeClaims,
timestamp: new Date().toISOString(),
});
}
/**
* Restore lock state from the session.
*/
export function restoreLockState(pi: {
getSessionName: () => string | undefined;
}): boolean {
const sessionName = pi.getSessionName();
if (!sessionName) return false;
// In a real implementation, this would read from session entries.
// For now, we return false and rely on in-memory state.
return false;
}
// ---------------------------------------------------------------------------
// Status bar integration
// ---------------------------------------------------------------------------
/**
* Update the status bar with lock information.
*/
export function updateLockStatus(
ui: ExtensionUIContext,
registry: ClaimRegistry,
): void {
const config = getConfig();
const activeClaims = Object.values(registry.claims).filter(
(c) => c.status === "active",
);
const lockCount = activeClaims.length;
ui.setStatus("file-claiming", `Claims: ${lockCount} active`);
}