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