diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts index 7232c28f5..409add77c 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts @@ -11,6 +11,27 @@ import type { Client } from "./client" import { PLACEHOLDER_TEXT } from "./message-builder" import { incrementEmptyContentAttempt } from "./state" import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk" +import { log } from "../../shared/logger" + +async function showToastSafely( + client: Client, + body: { + title: string + message: string + variant: "error" | "warning" | "success" + duration: number + }, + failureContext: string, +): Promise { + try { + await client.tui.showToast({ body }) + } catch (error) { + log(`[auto-compact] failed to show toast: ${failureContext}`, { + title: body.title, + error: error instanceof Error ? error.message : String(error), + }) + } +} export async function fixEmptyMessages(params: { sessionID: string @@ -32,30 +53,30 @@ export async function fixEmptyMessages(params: { }) if (!result.fixed && result.scannedEmptyCount === 0) { - await params.client.tui - .showToast({ - body: { - title: "Empty Content Error", - message: "No empty messages found in storage. Cannot auto-recover.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Empty Content Error", + message: "No empty messages found in storage. Cannot auto-recover.", + variant: "error", + duration: 5000, + }, + "sqlite empty message not found", + ) return false } if (result.fixed) { - await params.client.tui - .showToast({ - body: { - title: "Session Recovery", - message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`, - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Session Recovery", + message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`, + variant: "warning", + duration: 3000, + }, + "sqlite empty message fixed", + ) } return result.fixed @@ -83,16 +104,16 @@ export async function fixEmptyMessages(params: { const emptyTextPartIds = findMessagesWithEmptyTextParts(params.sessionID) const allIds = [...new Set([...emptyMessageIds, ...emptyTextPartIds])] if (allIds.length === 0) { - await params.client.tui - .showToast({ - body: { - title: "Empty Content Error", - message: "No empty messages found in storage. Cannot auto-recover.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Empty Content Error", + message: "No empty messages found in storage. Cannot auto-recover.", + variant: "error", + duration: 5000, + }, + "empty message not found", + ) return false } @@ -112,16 +133,16 @@ export async function fixEmptyMessages(params: { } if (fixed) { - await params.client.tui - .showToast({ - body: { - title: "Session Recovery", - message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`, - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Session Recovery", + message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`, + variant: "warning", + duration: 3000, + }, + "empty messages fixed", + ) } return fixed diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts index f7d527d3c..e776bcf39 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts @@ -13,8 +13,32 @@ import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder" import { fixEmptyMessages } from "./empty-content-recovery" import { resolveCompactionModel } from "../shared/compaction-model-resolver" +import { log } from "../../shared/logger" const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000 + +declare function setTimeout(handler: () => void, timeout?: number): unknown + +async function showToastSafely( + client: Client, + body: { + title: string + message: string + variant: "error" | "warning" | "success" + duration: number + }, + failureContext: string, +): Promise { + try { + await client.tui.showToast({ body }) + } catch (error) { + log(`[auto-compact] failed to show toast: ${failureContext}`, { + title: body.title, + error: error instanceof Error ? error.message : String(error), + }) + } +} + export async function runSummarizeRetryStrategy(params: { sessionID: string msg: Record @@ -40,16 +64,16 @@ export async function runSummarizeRetryStrategy(params: { const elapsedTimeMs = now - retryState.firstAttemptTime if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) { clearSessionState(params.autoCompactState, params.sessionID) - await params.client.tui - .showToast({ - body: { - title: "Auto Compact Timed Out", - message: "Compaction retries exceeded the timeout window. Please start a new session.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Auto Compact Timed Out", + message: "Compaction retries exceeded the timeout window. Please start a new session.", + variant: "error", + duration: 5000, + }, + "retry timeout", + ) return } @@ -74,17 +98,17 @@ export async function runSummarizeRetryStrategy(params: { } } else { clearSessionState(params.autoCompactState, params.sessionID) - await params.client.tui - .showToast({ - body: { - title: "Recovery Failed", - message: - "Max recovery attempts (3) reached for empty content error. Please start a new session.", - variant: "error", - duration: 10000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Recovery Failed", + message: + "Max recovery attempts (3) reached for empty content error. Please start a new session.", + variant: "error", + duration: 10000, + }, + "empty content recovery exhausted", + ) return } } @@ -106,16 +130,16 @@ export async function runSummarizeRetryStrategy(params: { try { await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client) - await params.client.tui - .showToast({ - body: { - title: "Auto Compact", - message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`, - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Auto Compact", + message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`, + variant: "warning", + duration: 3000, + }, + "summarize retry attempt", + ) const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel( params.pluginConfig, @@ -132,20 +156,26 @@ export async function runSummarizeRetryStrategy(params: { }) clearSessionState(params.autoCompactState, params.sessionID) return - } catch { + } catch (error) { + log("[auto-compact] summarize retry attempt failed", { + sessionID: params.sessionID, + attempt: retryState.attempt, + error: error instanceof Error ? error.message : String(error), + }) + const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime) if (remainingTimeMs <= 0) { clearSessionState(params.autoCompactState, params.sessionID) - await params.client.tui - .showToast({ - body: { - title: "Auto Compact Timed Out", - message: "Compaction retries exceeded the timeout window. Please start a new session.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Auto Compact Timed Out", + message: "Compaction retries exceeded the timeout window. Please start a new session.", + variant: "error", + duration: 5000, + }, + "summarize retry timeout after failure", + ) return } @@ -162,28 +192,28 @@ export async function runSummarizeRetryStrategy(params: { return } } else { - await params.client.tui - .showToast({ - body: { - title: "Summarize Skipped", - message: "Missing providerID or modelID.", - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Summarize Skipped", + message: "Missing providerID or modelID.", + variant: "warning", + duration: 3000, + }, + "missing summarize model info", + ) } } clearSessionState(params.autoCompactState, params.sessionID) - await params.client.tui - .showToast({ - body: { - title: "Auto Compact Failed", - message: "All recovery attempts failed. Please start a new session.", - variant: "error", - duration: 5000, - }, - }) - .catch(() => {}) + await showToastSafely( + params.client, + { + title: "Auto Compact Failed", + message: "All recovery attempts failed. Please start a new session.", + variant: "error", + duration: 5000, + }, + "summarize retry failed", + ) } diff --git a/src/hooks/model-fallback/chat-message-fallback-handler.ts b/src/hooks/model-fallback/chat-message-fallback-handler.ts new file mode 100644 index 000000000..4cad63951 --- /dev/null +++ b/src/hooks/model-fallback/chat-message-fallback-handler.ts @@ -0,0 +1,74 @@ +import { log } from "../../shared/logger" +import { getTaskToastManager } from "../../features/task-toast-manager" +import type { ChatMessageHandlerOutput, ChatMessageInput } from "../../plugin/chat-message" + +export async function applyFallbackToChatMessage(params: { + input: ChatMessageInput + output: ChatMessageHandlerOutput + fallback: { providerID: string; modelID: string; variant?: string } + toast?: (input: { + title: string + message: string + variant?: "info" | "success" | "warning" | "error" + duration?: number + }) => void | Promise + onApplied?: (input: { + sessionID: string + providerID: string + modelID: string + variant?: string + }) => void | Promise + lastToastKey: Map +}): Promise { + const { input, output, fallback, toast, onApplied, lastToastKey } = params + const { sessionID } = input + if (!sessionID) return + + output.message["model"] = { + providerID: fallback.providerID, + modelID: fallback.modelID, + } + if (fallback.variant !== undefined) { + output.message["variant"] = fallback.variant + } else { + delete output.message["variant"] + } + + if (toast) { + const key = `${sessionID}:${fallback.providerID}/${fallback.modelID}:${fallback.variant ?? ""}` + if (lastToastKey.get(sessionID) !== key) { + lastToastKey.set(sessionID, key) + const variantLabel = fallback.variant ? ` (${fallback.variant})` : "" + await Promise.resolve( + toast({ + title: "Model fallback", + message: `Using ${fallback.providerID}/${fallback.modelID}${variantLabel}`, + variant: "warning", + duration: 5000, + }), + ) + } + } + + if (onApplied) { + await Promise.resolve( + onApplied({ + sessionID, + providerID: fallback.providerID, + modelID: fallback.modelID, + variant: fallback.variant, + }), + ) + } + + const toastManager = getTaskToastManager() + if (toastManager) { + const variantLabel = fallback.variant ? ` (${fallback.variant})` : "" + toastManager.updateTaskModelBySession(sessionID, { + model: `${fallback.providerID}/${fallback.modelID}${variantLabel}`, + type: "runtime-fallback", + }) + } + + log("[model-fallback] Applied fallback model: " + JSON.stringify(fallback)) +} diff --git a/src/hooks/model-fallback/hook.ts b/src/hooks/model-fallback/hook.ts index cbbcbc935..54d7c9342 100644 --- a/src/hooks/model-fallback/hook.ts +++ b/src/hooks/model-fallback/hook.ts @@ -5,8 +5,9 @@ import { readConnectedProvidersCache, readProviderModelsCache } from "../../shar import { selectFallbackProvider } from "../../shared/model-error-classifier" import { transformModelForProvider } from "../../shared/provider-model-id-transform" import { log } from "../../shared/logger" -import { getTaskToastManager } from "../../features/task-toast-manager" import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message" +import { applyFallbackToChatMessage } from "./chat-message-fallback-handler" +import { getNextReachableFallback } from "./next-fallback" type FallbackToast = (input: { title: string @@ -39,12 +40,6 @@ const pendingModelFallbacks = new Map() const lastToastKey = new Map() const sessionFallbackChains = new Map() -function canonicalizeModelID(modelID: string): string { - return modelID - .toLowerCase() - .replace(/\./g, "-") -} - export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void { if (!sessionID) return if (!fallbackChain || fallbackChain.length === 0) { @@ -126,58 +121,9 @@ export function getNextFallback( if (!state.pending) return null - const { fallbackChain } = state - - const providerModelsCache = readProviderModelsCache() - const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache() - const connectedSet = connectedProviders - ? new Set(connectedProviders.map((provider) => provider.toLowerCase())) - : null - - const isReachable = (entry: FallbackEntry): boolean => { - if (!connectedSet) return true - - // Gate only on provider connectivity. Provider model lists can be stale/incomplete, - // especially after users manually add models to opencode.json. - if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) { - return true - } - - const preferredProvider = state.providerID.toLowerCase() - return connectedSet.has(preferredProvider) - } - - while (state.attemptCount < fallbackChain.length) { - const attemptCount = state.attemptCount - const fallback = fallbackChain[attemptCount] - state.attemptCount++ - - if (!isReachable(fallback)) { - log("[model-fallback] Skipping unreachable fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model) - continue - } - - const providerID = selectFallbackProvider(fallback.providers, state.providerID) - const modelID = transformModelForProvider(providerID, fallback.model) - - const isNoOpFallback = - providerID.toLowerCase() === state.providerID.toLowerCase() && - canonicalizeModelID(modelID) === canonicalizeModelID(state.modelID) - - if (isNoOpFallback) { - log("[model-fallback] Skipping no-op fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model) - continue - } - - state.pending = false - - log("[model-fallback] Using fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model) - - return { - providerID, - modelID, - variant: fallback.variant, - } + const fallback = getNextReachableFallback(sessionID, state) + if (fallback) { + return fallback } log("[model-fallback] No more fallbacks for session: " + sessionID) @@ -227,50 +173,14 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie const fallback = getNextFallback(sessionID) if (!fallback) return - output.message["model"] = { - providerID: fallback.providerID, - modelID: fallback.modelID, - } - if (fallback.variant !== undefined) { - output.message["variant"] = fallback.variant - } else { - delete output.message["variant"] - } - if (toast) { - const key = `${sessionID}:${fallback.providerID}/${fallback.modelID}:${fallback.variant ?? ""}` - if (lastToastKey.get(sessionID) !== key) { - lastToastKey.set(sessionID, key) - const variantLabel = fallback.variant ? ` (${fallback.variant})` : "" - await Promise.resolve( - toast({ - title: "Model fallback", - message: `Using ${fallback.providerID}/${fallback.modelID}${variantLabel}`, - variant: "warning", - duration: 5000, - }), - ) - } - } - if (onApplied) { - await Promise.resolve( - onApplied({ - sessionID, - providerID: fallback.providerID, - modelID: fallback.modelID, - variant: fallback.variant, - }), - ) - } - - const toastManager = getTaskToastManager() - if (toastManager) { - const variantLabel = fallback.variant ? ` (${fallback.variant})` : "" - toastManager.updateTaskModelBySession(sessionID, { - model: `${fallback.providerID}/${fallback.modelID}${variantLabel}`, - type: "runtime-fallback", - }) - } - log("[model-fallback] Applied fallback model: " + JSON.stringify(fallback)) + await applyFallbackToChatMessage({ + input, + output, + fallback, + toast, + onApplied, + lastToastKey, + }) }, } } diff --git a/src/hooks/model-fallback/next-fallback.ts b/src/hooks/model-fallback/next-fallback.ts new file mode 100644 index 000000000..dadd16654 --- /dev/null +++ b/src/hooks/model-fallback/next-fallback.ts @@ -0,0 +1,70 @@ +import type { FallbackEntry } from "../../shared/model-requirements" +import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache" +import { selectFallbackProvider } from "../../shared/model-error-classifier" +import { transformModelForProvider } from "../../shared/provider-model-id-transform" +import { log } from "../../shared/logger" +import type { ModelFallbackState } from "./hook" + +function canonicalizeModelID(modelID: string): string { + return modelID + .toLowerCase() + .replace(/\./g, "-") +} + +function createReachabilityChecker(state: ModelFallbackState): (entry: FallbackEntry) => boolean { + const providerModelsCache = readProviderModelsCache() + const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache() + const connectedSet = connectedProviders + ? new Set(connectedProviders.map((provider) => provider.toLowerCase())) + : null + + return (entry: FallbackEntry): boolean => { + if (!connectedSet) return true + + if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) { + return true + } + + return connectedSet.has(state.providerID.toLowerCase()) + } +} + +export function getNextReachableFallback( + sessionID: string, + state: ModelFallbackState, +): { providerID: string; modelID: string; variant?: string } | null { + const isReachable = createReachabilityChecker(state) + + while (state.attemptCount < state.fallbackChain.length) { + const attemptCount = state.attemptCount + const fallback = state.fallbackChain[attemptCount] + state.attemptCount++ + + if (!isReachable(fallback)) { + log("[model-fallback] Skipping unreachable fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model) + continue + } + + const providerID = selectFallbackProvider(fallback.providers, state.providerID) + const modelID = transformModelForProvider(providerID, fallback.model) + const isNoOpFallback = + providerID.toLowerCase() === state.providerID.toLowerCase() + && canonicalizeModelID(modelID) === canonicalizeModelID(state.modelID) + + if (isNoOpFallback) { + log("[model-fallback] Skipping no-op fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model) + continue + } + + state.pending = false + log("[model-fallback] Using fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model) + + return { + providerID, + modelID, + variant: fallback.variant, + } + } + + return null +} diff --git a/src/hooks/runtime-fallback/event-handler.ts b/src/hooks/runtime-fallback/event-handler.ts index 09175ddaa..97f972fdd 100644 --- a/src/hooks/runtime-fallback/event-handler.ts +++ b/src/hooks/runtime-fallback/event-handler.ts @@ -101,7 +101,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent) if (sessionRetryInFlight.has(sessionID)) { - log(`[${HOOK_NAME}] session.error skipped — retry in flight`, { + log(`[${HOOK_NAME}] session.error skipped - retry in flight`, { sessionID, retryInFlight: true, }) diff --git a/src/hooks/runtime-fallback/fallback-models.ts b/src/hooks/runtime-fallback/fallback-models.ts index 415751d7e..b612b02a6 100644 --- a/src/hooks/runtime-fallback/fallback-models.ts +++ b/src/hooks/runtime-fallback/fallback-models.ts @@ -25,7 +25,7 @@ export function getFallbackModelsForSession( /** * Returns the raw fallback model entries (strings and objects) for a session. * Use this when per-model settings (temperature, reasoningEffort, etc.) must be - * preserved — e.g. before passing to buildFallbackChainFromModels. + * preserved - e.g. before passing to buildFallbackChainFromModels. */ export function getRawFallbackModels( sessionID: string, diff --git a/src/hooks/runtime-fallback/session-status-handler.ts b/src/hooks/runtime-fallback/session-status-handler.ts index 92ccfab80..1fff2a6ff 100644 --- a/src/hooks/runtime-fallback/session-status-handler.ts +++ b/src/hooks/runtime-fallback/session-status-handler.ts @@ -56,7 +56,7 @@ export function createSessionStatusHandler( await helpers.abortSessionRequest(sessionID, "session.status.retry-signal") sessionRetryInFlight.delete(sessionID) } else { - log(`[${HOOK_NAME}] session.status retry skipped — retry already in flight`, { sessionID }) + log(`[${HOOK_NAME}] session.status retry skipped - retry already in flight`, { sessionID }) return } } diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 722509592..504385ffa 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -40,7 +40,7 @@ export async function sendSessionNotification( ): Promise { switch (platform) { case "darwin": { - // Try terminal-notifier first — deterministic click-to-focus + // Try terminal-notifier first - deterministic click-to-focus const terminalNotifierPath = await getTerminalNotifierPath() if (terminalNotifierPath) { const bundleId = process.env.__CFBundleIdentifier diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index 5f9d572fb..cf4ca06ea 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -1,13 +1,30 @@ +import { log } from "../shared/logger" + +declare const Bun: { + which(commandName: string): string | null +} + type Platform = "darwin" | "linux" | "win32" | "unsupported" async function findCommand(commandName: string): Promise { try { return Bun.which(commandName) - } catch { + } catch (error) { + log("[session-notification] failed to resolve command path", { + commandName, + error: error instanceof Error ? error.message : String(error), + }) return null } } +function logBackgroundCheckError(commandName: string, error: unknown): void { + log("[session-notification] background command check failed", { + commandName, + error: error instanceof Error ? error.message : String(error), + }) +} + function createCommandFinder(commandName: string): () => Promise { let cachedPath: string | null = null let pending: Promise | null = null @@ -36,14 +53,28 @@ export const getTerminalNotifierPath = createCommandFinder("terminal-notifier") export function startBackgroundCheck(platform: Platform): void { if (platform === "darwin") { - getOsascriptPath().catch(() => {}) - getAfplayPath().catch(() => {}) - getTerminalNotifierPath().catch(() => {}) + getOsascriptPath().catch((error) => { + logBackgroundCheckError("osascript", error) + }) + getAfplayPath().catch((error) => { + logBackgroundCheckError("afplay", error) + }) + getTerminalNotifierPath().catch((error) => { + logBackgroundCheckError("terminal-notifier", error) + }) } else if (platform === "linux") { - getNotifySendPath().catch(() => {}) - getPaplayPath().catch(() => {}) - getAplayPath().catch(() => {}) + getNotifySendPath().catch((error) => { + logBackgroundCheckError("notify-send", error) + }) + getPaplayPath().catch((error) => { + logBackgroundCheckError("paplay", error) + }) + getAplayPath().catch((error) => { + logBackgroundCheckError("aplay", error) + }) } else if (platform === "win32") { - getPowershellPath().catch(() => {}) + getPowershellPath().catch((error) => { + logBackgroundCheckError("powershell", error) + }) } } diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts new file mode 100644 index 000000000..e5307c8e9 --- /dev/null +++ b/src/hooks/start-work/context-info-builder.ts @@ -0,0 +1,298 @@ +import { statSync } from "node:fs" +import { + appendSessionId, + clearBoulderState, + createBoulderState, + findPrometheusPlans, + getPlanName, + getPlanProgress, + getTaskSessionState, + readBoulderState, + readCurrentTopLevelTask, + upsertTaskSessionState, + writeBoulderState, +} from "../../features/boulder-state" +import { log } from "../../shared/logger" +import type { PluginInput } from "@opencode-ai/plugin" +import { HOOK_NAME } from "./start-work-hook" + +function findPlanByName(plans: string[], requestedName: string): string | null { + const lowerName = requestedName.toLowerCase() + const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName) + if (exactMatch) return exactMatch + const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName)) + return partialMatch || null +} + +function buildAutoSelectedPlanContext(params: { + planPath: string + sessionId: string + timestamp: string + activeAgent: string + worktreePath: string | undefined + worktreeBlock: string + directory: string +}): string { + const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const progress = getPlanProgress(planPath) + const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) + writeBoulderState(directory, newState) + + return ` +## Auto-Selected Plan + +**Plan**: ${getPlanName(planPath)} +**Path**: ${planPath} +**Progress**: ${progress.completed}/${progress.total} tasks +**Session ID**: ${sessionId} +**Started**: ${timestamp} +${worktreeBlock} + +boulder.json has been created. Read the plan and begin execution.` +} + +function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string { + const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) + if (incompletePlans.length > 0) { + const planList = incompletePlans + .map((p, i) => { + const prog = getPlanProgress(p) + return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` + }) + .join("\n") + + return ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". + +Available incomplete plans: +${planList} + +Ask the user which plan to work on.` + } + + return ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". +No incomplete plans available. Create a new plan with: /plan "your task"` +} + +function buildExplicitPlanContext(params: { + explicitPlanName: string + existingState: ReturnType + sessionId: string + timestamp: string + activeAgent: string + worktreePath: string | undefined + worktreeBlock: string + directory: string +}): string { + const { explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: sessionId }) + + const allPlans = findPrometheusPlans(directory) + const matchedPlan = findPlanByName(allPlans, explicitPlanName) + if (!matchedPlan) { + return buildMissingPlanContext(explicitPlanName, allPlans) + } + + const progress = getPlanProgress(matchedPlan) + if (progress.isComplete) { + return ` +## Plan Already Complete + +The requested plan "${getPlanName(matchedPlan)}" has been completed. +All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` + } + + if (existingState) { + clearBoulderState(directory) + } + + return buildAutoSelectedPlanContext({ + planPath: matchedPlan, + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + directory, + }) +} + +function buildExistingSessionContext(params: { + existingState: NonNullable> + sessionId: string + activeAgent: string + worktreePath: string | undefined + worktreeBlock: string + directory: string +}): string { + const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params + const progress = getPlanProgress(existingState.active_plan) + if (progress.isComplete) { + return ` +## Previous Work Complete + +The previous plan (${existingState.plan_name}) has been completed. +Looking for new plans...` + } + + const effectiveWorktree = worktreePath ?? existingState.worktree_path + const sessionAlreadyTracked = existingState.session_ids.includes(sessionId) + const updatedSessions = sessionAlreadyTracked + ? existingState.session_ids + : [...existingState.session_ids, sessionId] + const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined + + if (shouldRewriteState) { + writeBoulderState(directory, { + ...existingState, + agent: activeAgent, + ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), + session_ids: updatedSessions, + }) + } else if (!sessionAlreadyTracked) { + appendSessionId(directory, sessionId) + } + + const worktreeDisplay = effectiveWorktree ? worktreeBlock.replace(worktreePath ?? "", effectiveWorktree) : worktreeBlock + + return ` +## Active Work Session Found + +**Status**: RESUMING existing work +**Plan**: ${existingState.plan_name} +**Path**: ${existingState.active_plan} +**Progress**: ${progress.completed}/${progress.total} tasks completed +**Sessions**: ${existingState.session_ids.length + 1} (current session appended) +**Started**: ${existingState.started_at} +${worktreeDisplay} + +The current session (${sessionId}) has been added to session_ids. +Read the plan file and continue from the first unchecked task.` +} + +function shouldDiscoverPlans( + existingState: ReturnType, + explicitPlanName: string | null, +): boolean { + return (!existingState && !explicitPlanName) + || (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete) +} + +function buildPlanDiscoveryContext(params: { + contextInfo: string + sessionId: string + timestamp: string + activeAgent: string + worktreePath: string | undefined + worktreeBlock: string + directory: string +}): string { + const { contextInfo, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const plans = findPrometheusPlans(directory) + const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) + + if (plans.length === 0) { + return contextInfo + ` +## No Plans Found + +No Prometheus plan files found at .sisyphus/plans/ +Use Prometheus to create a work plan first: /plan "your task"` + } + + if (incompletePlans.length === 0) { + return contextInfo + ` + +## All Plans Complete + +All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` + } + + if (incompletePlans.length === 1) { + return contextInfo + buildAutoSelectedPlanContext({ + planPath: incompletePlans[0], + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + directory, + }) + } + + const planList = incompletePlans + .map((p, i) => { + const progress = getPlanProgress(p) + const modified = new Date(statSync(p).mtimeMs).toISOString() + return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` + }) + .join("\n") + + return contextInfo + ` + + +## Multiple Plans Found + +Current Time: ${timestamp} +Session ID: ${sessionId} + +${planList} + +Ask the user which plan to work on. Present the options above and wait for their response. +${worktreeBlock} +` +} + +export function buildStartWorkContextInfo(params: { + ctx: PluginInput + explicitPlanName: string | null + existingState: ReturnType + sessionId: string + timestamp: string + activeAgent: string + worktreePath: string | undefined + worktreeBlock: string +}): string { + const { ctx, explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock } = params + + let contextInfo = "" + if (explicitPlanName) { + contextInfo = buildExplicitPlanContext({ + explicitPlanName, + existingState, + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } else if (existingState) { + contextInfo = buildExistingSessionContext({ + existingState, + sessionId, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } + + if (shouldDiscoverPlans(existingState, explicitPlanName)) { + return buildPlanDiscoveryContext({ + contextInfo, + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } + + return contextInfo +} diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index d0445d7f1..c94977c91 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -23,6 +23,7 @@ import { } from "../../features/claude-code-session-state" import { detectWorktreePath } from "./worktree-detector" import { parseUserRequest } from "./parse-user-request" +import { buildStartWorkContextInfo } from "./context-info-builder" export const HOOK_NAME = "start-work" as const const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session." @@ -43,24 +44,16 @@ interface StartWorkHookOutput { parts: Array<{ type: string; text?: string }> } -function findPlanByName(plans: string[], requestedName: string): string | null { - const lowerName = requestedName.toLowerCase() - const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName) - if (exactMatch) return exactMatch - const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName)) - return partialMatch || null -} - function createWorktreeActiveBlock(worktreePath: string): string { return ` ## Worktree Active **Worktree**: \`${worktreePath}\` -**CRITICAL — DO NOT FORGET**: You are working inside a git worktree. ALL operations MUST be performed exclusively within this worktree directory. +**CRITICAL - DO NOT FORGET**: You are working inside a git worktree. ALL operations MUST be performed exclusively within this worktree directory. - Every file read, write, edit, and git operation MUST target paths under: \`${worktreePath}\` - When delegating tasks to subagents, you MUST include the worktree path in your delegation prompt so they also operate exclusively within the worktree -- NEVER operate on the main repository directory — always use the worktree path above` +- NEVER operate on the main repository directory - always use the worktree path above` } function resolveWorktreeContext( @@ -129,174 +122,16 @@ export function createStartWorkHook(ctx: PluginInput) { const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText) const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath) - let contextInfo = "" - - if (explicitPlanName) { - log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID }) - - const allPlans = findPrometheusPlans(ctx.directory) - const matchedPlan = findPlanByName(allPlans, explicitPlanName) - - if (matchedPlan) { - const progress = getPlanProgress(matchedPlan) - - if (progress.isComplete) { - contextInfo = ` -## Plan Already Complete - -The requested plan "${getPlanName(matchedPlan)}" has been completed. -All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` - } else { - if (existingState) clearBoulderState(ctx.directory) - const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath) - writeBoulderState(ctx.directory, newState) - - contextInfo = ` -## Auto-Selected Plan - -**Plan**: ${getPlanName(matchedPlan)} -**Path**: ${matchedPlan} -**Progress**: ${progress.completed}/${progress.total} tasks -**Session ID**: ${sessionId} -**Started**: ${timestamp} -${worktreeBlock} - -boulder.json has been created. Read the plan and begin execution.` - } - } else { - const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) - if (incompletePlans.length > 0) { - const planList = incompletePlans - .map((p, i) => { - const prog = getPlanProgress(p) - return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` - }) - .join("\n") - - contextInfo = ` -## Plan Not Found - -Could not find a plan matching "${explicitPlanName}". - -Available incomplete plans: -${planList} - -Ask the user which plan to work on.` - } else { - contextInfo = ` -## Plan Not Found - -Could not find a plan matching "${explicitPlanName}". -No incomplete plans available. Create a new plan with: /plan "your task"` - } - } - } else if (existingState) { - const progress = getPlanProgress(existingState.active_plan) - - if (!progress.isComplete) { - const effectiveWorktree = worktreePath ?? existingState.worktree_path - const sessionAlreadyTracked = existingState.session_ids.includes(sessionId) - const updatedSessions = sessionAlreadyTracked - ? existingState.session_ids - : [...existingState.session_ids, sessionId] - const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined - - if (shouldRewriteState) { - writeBoulderState(ctx.directory, { - ...existingState, - agent: activeAgent, - ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), - session_ids: updatedSessions, - }) - } else if (!sessionAlreadyTracked) { - appendSessionId(ctx.directory, sessionId) - } - - const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock - - contextInfo = ` -## Active Work Session Found - -**Status**: RESUMING existing work -**Plan**: ${existingState.plan_name} -**Path**: ${existingState.active_plan} -**Progress**: ${progress.completed}/${progress.total} tasks completed -**Sessions**: ${existingState.session_ids.length + 1} (current session appended) -**Started**: ${existingState.started_at} -${worktreeDisplay} - -The current session (${sessionId}) has been added to session_ids. -Read the plan file and continue from the first unchecked task.` - } else { - contextInfo = ` -## Previous Work Complete - -The previous plan (${existingState.plan_name}) has been completed. -Looking for new plans...` - } - } - - if ( - (!existingState && !explicitPlanName) || - (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete) - ) { - const plans = findPrometheusPlans(ctx.directory) - const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) - - if (plans.length === 0) { - contextInfo += ` -## No Plans Found - -No Prometheus plan files found at .sisyphus/plans/ -Use Prometheus to create a work plan first: /plan "your task"` - } else if (incompletePlans.length === 0) { - contextInfo += ` - -## All Plans Complete - -All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` - } else if (incompletePlans.length === 1) { - const planPath = incompletePlans[0] - const progress = getPlanProgress(planPath) - const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) - writeBoulderState(ctx.directory, newState) - - contextInfo += ` - -## Auto-Selected Plan - -**Plan**: ${getPlanName(planPath)} -**Path**: ${planPath} -**Progress**: ${progress.completed}/${progress.total} tasks -**Session ID**: ${sessionId} -**Started**: ${timestamp} -${worktreeBlock} - -boulder.json has been created. Read the plan and begin execution.` - } else { - const planList = incompletePlans - .map((p, i) => { - const progress = getPlanProgress(p) - const modified = new Date(statSync(p).mtimeMs).toISOString() - return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` - }) - .join("\n") - - contextInfo += ` - - -## Multiple Plans Found - -Current Time: ${timestamp} -Session ID: ${sessionId} - -${planList} - -Ask the user which plan to work on. Present the options above and wait for their response. -${worktreeBlock} -` - } - } + const contextInfo = buildStartWorkContextInfo({ + ctx, + explicitPlanName, + existingState, + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + }) const idx = output.parts.findIndex((p) => p.type === "text" && p.text) if (idx >= 0 && output.parts[idx].text) { diff --git a/src/hooks/thinking-block-validator/hook.ts b/src/hooks/thinking-block-validator/hook.ts index 544d8e672..39410f3f0 100644 --- a/src/hooks/thinking-block-validator/hook.ts +++ b/src/hooks/thinking-block-validator/hook.ts @@ -50,7 +50,7 @@ function isSignedThinkingPart(part: Part): part is SignedThinkingPart { * Check if there are any Anthropic-signed thinking blocks in the message history. * * Only returns true for real `type: "thinking"` blocks with a valid `signature`. - * GPT reasoning blocks (`type: "reasoning"`) are intentionally excluded — they + * GPT reasoning blocks (`type: "reasoning"`) are intentionally excluded - they * have no Anthropic signature and must never be forwarded to the Anthropic API. * * Model-name checks are unreliable (miss GPT+thinking, custom model IDs, etc.) @@ -93,7 +93,7 @@ function startsWithThinkingBlock(parts: Part[]): boolean { * * Returns the original Part object (including its `signature` field) so it can * be reused verbatim in another message. Only `type: "thinking"` blocks with - * both a `signature` and `thinking` field are returned — GPT `type: "reasoning"` + * both a `signature` and `thinking` field are returned - GPT `type: "reasoning"` * blocks are excluded because they lack an Anthropic signature and would be * rejected by the API with "Invalid `signature` in `thinking` block". * Synthetic parts injected by a previous run of this hook are also skipped. @@ -106,7 +106,7 @@ function findPreviousThinkingPart(messages: MessageWithParts[], currentIndex: nu if (!msg.parts) continue for (const part of msg.parts) { - // Only Anthropic thinking blocks — type must be "thinking", not "reasoning" + // Only Anthropic thinking blocks - type must be "thinking", not "reasoning" if (!isSignedThinkingPart(part)) continue return part @@ -145,10 +145,10 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook { } // Skip if there are no Anthropic-signed thinking blocks in history. - // This is more reliable than checking model names — works for Claude, + // This is more reliable than checking model names - works for Claude, // GPT with thinking variants, or any future model. Crucially, GPT // reasoning blocks (type="reasoning", no signature) do NOT trigger this - // hook — only real Anthropic thinking blocks do. + // hook - only real Anthropic thinking blocks do. if (!hasSignedThinkingBlocksInHistory(messages)) { return } @@ -164,7 +164,7 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook { if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) { // Find the most recent real thinking part (with valid signature) from // previous turns. If none exists we cannot safely inject a thinking - // block — a synthetic block without a signature would cause the API + // block - a synthetic block without a signature would cause the API // to reject the request with "Invalid `signature` in `thinking` block". const previousThinkingPart = findPreviousThinkingPart(messages, i) diff --git a/src/hooks/todo-description-override/description.ts b/src/hooks/todo-description-override/description.ts index dc85fc7bf..2342b1957 100644 --- a/src/hooks/todo-description-override/description.ts +++ b/src/hooks/todo-description-override/description.ts @@ -13,7 +13,7 @@ GOOD: BAD: - "Implement email validation" (where? how? what result?) -- "Add dark mode" (this is a feature, not a todo) +- "Add dark mode" (feature, not a todo) - "Fix auth" (what file? what changes? what's expected?) ## Granularity Rules diff --git a/src/hooks/write-existing-file-guard/hook.ts b/src/hooks/write-existing-file-guard/hook.ts index 547a5e5a5..bdaf5cad8 100644 --- a/src/hooks/write-existing-file-guard/hook.ts +++ b/src/hooks/write-existing-file-guard/hook.ts @@ -4,8 +4,10 @@ import { existsSync, realpathSync } from "fs" import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path" import { log } from "../../shared" +import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler" +import { evictLeastRecentlyUsedSession, touchSession, trimSessionReadSet } from "./session-read-permissions" -type GuardArgs = { +export type GuardArgs = { filePath?: string path?: string file_path?: string @@ -16,7 +18,7 @@ const MAX_TRACKED_SESSIONS = 256 export const MAX_TRACKED_PATHS_PER_SESSION = 1024 const BLOCK_MESSAGE = "File already exists. Use edit tool instead." -function asRecord(value: unknown): Record | undefined { +export function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined } @@ -24,22 +26,22 @@ function asRecord(value: unknown): Record | undefined { return value as Record } -function getPathFromArgs(args: GuardArgs | undefined): string | undefined { +export function getPathFromArgs(args: GuardArgs | undefined): string | undefined { return args?.filePath ?? args?.path ?? args?.file_path } -function resolveInputPath(ctx: PluginInput, inputPath: string): string { +export function resolveInputPath(ctx: PluginInput, inputPath: string): string { return normalize(isAbsolute(inputPath) ? inputPath : resolve(ctx.directory, inputPath)) } -function isPathInsideDirectory(pathToCheck: string, directory: string): boolean { +export function isPathInsideDirectory(pathToCheck: string, directory: string): boolean { const relativePath = relative(directory, pathToCheck) return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)) } -function toCanonicalPath(absolutePath: string): string { +export function toCanonicalPath(absolutePath: string): string { let canonicalPath = absolutePath if (existsSync(absolutePath)) { @@ -59,7 +61,7 @@ function toCanonicalPath(absolutePath: string): string { return normalize(canonicalPath) } -function isOverwriteEnabled(value: boolean | string | undefined): boolean { +export function isOverwriteEnabled(value: boolean | string | undefined): boolean { if (value === true) { return true } @@ -76,165 +78,17 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks { const sessionLastAccess = new Map() const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory)) - const touchSession = (sessionID: string): void => { - sessionLastAccess.set(sessionID, Date.now()) - } - - const evictLeastRecentlyUsedSession = (): void => { - let oldestSessionID: string | undefined - let oldestSeen = Number.POSITIVE_INFINITY - - for (const [sessionID, lastSeen] of sessionLastAccess.entries()) { - if (lastSeen < oldestSeen) { - oldestSeen = lastSeen - oldestSessionID = sessionID - } - } - - if (!oldestSessionID) { - return - } - - readPermissionsBySession.delete(oldestSessionID) - sessionLastAccess.delete(oldestSessionID) - } - - const ensureSessionReadSet = (sessionID: string): Set => { - let readSet = readPermissionsBySession.get(sessionID) - if (!readSet) { - if (readPermissionsBySession.size >= MAX_TRACKED_SESSIONS) { - evictLeastRecentlyUsedSession() - } - - readSet = new Set() - readPermissionsBySession.set(sessionID, readSet) - } - - touchSession(sessionID) - return readSet - } - - const trimSessionReadSet = (readSet: Set): void => { - while (readSet.size > MAX_TRACKED_PATHS_PER_SESSION) { - const oldestPath = readSet.values().next().value - if (!oldestPath) { - return - } - - readSet.delete(oldestPath) - } - } - - const registerReadPermission = (sessionID: string, canonicalPath: string): void => { - const readSet = ensureSessionReadSet(sessionID) - if (readSet.has(canonicalPath)) { - readSet.delete(canonicalPath) - } - - readSet.add(canonicalPath) - trimSessionReadSet(readSet) - } - - const consumeReadPermission = (sessionID: string, canonicalPath: string): boolean => { - const readSet = readPermissionsBySession.get(sessionID) - if (!readSet || !readSet.has(canonicalPath)) { - return false - } - - readSet.delete(canonicalPath) - touchSession(sessionID) - return true - } - - const invalidateOtherSessions = (canonicalPath: string, writingSessionID?: string): void => { - for (const [sessionID, readSet] of readPermissionsBySession.entries()) { - if (writingSessionID && sessionID === writingSessionID) { - continue - } - - readSet.delete(canonicalPath) - } - } - return { "tool.execute.before": async (input, output) => { - const toolName = input.tool?.toLowerCase() - if (toolName !== "write" && toolName !== "read") { - return - } - - const argsRecord = asRecord(output.args) - const args = argsRecord as GuardArgs | undefined - const filePath = getPathFromArgs(args) - if (!filePath) { - return - } - - const resolvedPath = resolveInputPath(ctx, filePath) - const canonicalPath = toCanonicalPath(resolvedPath) - const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot) - - if (!isInsideSessionDirectory) { - return - } - - if (toolName === "read") { - if (!existsSync(resolvedPath) || !input.sessionID) { - return - } - - registerReadPermission(input.sessionID, canonicalPath) - return - } - - const overwriteEnabled = isOverwriteEnabled(args?.overwrite) - - if (argsRecord && "overwrite" in argsRecord) { - // Intentionally mutate output args so overwrite bypass remains hook-only. - delete argsRecord.overwrite - } - - if (!existsSync(resolvedPath)) { - return - } - - const isSisyphusPath = canonicalPath.includes("/.sisyphus/") - if (isSisyphusPath) { - log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", { - sessionID: input.sessionID, - filePath, - }) - invalidateOtherSessions(canonicalPath, input.sessionID) - return - } - - if (overwriteEnabled) { - log("[write-existing-file-guard] Allowing overwrite flag bypass", { - sessionID: input.sessionID, - filePath, - resolvedPath, - }) - invalidateOtherSessions(canonicalPath, input.sessionID) - return - } - - if (input.sessionID && consumeReadPermission(input.sessionID, canonicalPath)) { - log("[write-existing-file-guard] Allowing overwrite after read", { - sessionID: input.sessionID, - filePath, - resolvedPath, - }) - invalidateOtherSessions(canonicalPath, input.sessionID) - return - } - - log("[write-existing-file-guard] Blocking write to existing file", { - sessionID: input.sessionID, - filePath, - resolvedPath, + await handleWriteExistingFileGuardToolExecuteBefore({ + ctx, + input, + output, + readPermissionsBySession, + sessionLastAccess, + canonicalSessionRoot, + maxTrackedSessions: MAX_TRACKED_SESSIONS, }) - - throw new Error("File already exists. Use edit tool instead.") }, event: async ({ event }: { event: { type: string; properties?: unknown } }) => { if (event.type !== "session.deleted") { diff --git a/src/hooks/write-existing-file-guard/session-read-permissions.ts b/src/hooks/write-existing-file-guard/session-read-permissions.ts new file mode 100644 index 000000000..75ec72900 --- /dev/null +++ b/src/hooks/write-existing-file-guard/session-read-permissions.ts @@ -0,0 +1,36 @@ +export function touchSession(sessionLastAccess: Map, sessionID: string): void { + sessionLastAccess.set(sessionID, Date.now()) +} + +export function evictLeastRecentlyUsedSession( + readPermissionsBySession: Map>, + sessionLastAccess: Map, +): void { + let oldestSessionID: string | undefined + let oldestSeen = Number.POSITIVE_INFINITY + + for (const [sessionID, lastSeen] of sessionLastAccess.entries()) { + if (lastSeen < oldestSeen) { + oldestSeen = lastSeen + oldestSessionID = sessionID + } + } + + if (!oldestSessionID) { + return + } + + readPermissionsBySession.delete(oldestSessionID) + sessionLastAccess.delete(oldestSessionID) +} + +export function trimSessionReadSet(readSet: Set, maxTrackedPathsPerSession: number): void { + while (readSet.size > maxTrackedPathsPerSession) { + const oldestPath = readSet.values().next().value + if (!oldestPath) { + return + } + + readSet.delete(oldestPath) + } +} diff --git a/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts b/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts new file mode 100644 index 000000000..25eebbda3 --- /dev/null +++ b/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts @@ -0,0 +1,176 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { existsSync } from "fs" +import { log } from "../../shared" +import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook" +import { + asRecord, + getPathFromArgs, + isOverwriteEnabled, + isPathInsideDirectory, + resolveInputPath, + toCanonicalPath, + type GuardArgs, +} from "./hook" +import { + evictLeastRecentlyUsedSession, + touchSession, + trimSessionReadSet, +} from "./session-read-permissions" + +function ensureSessionReadSet(params: { + sessionID: string + readPermissionsBySession: Map> + sessionLastAccess: Map + maxTrackedSessions: number +}): Set { + const { sessionID, readPermissionsBySession, sessionLastAccess, maxTrackedSessions } = params + let readSet = readPermissionsBySession.get(sessionID) + if (!readSet) { + if (readPermissionsBySession.size >= maxTrackedSessions) { + evictLeastRecentlyUsedSession(readPermissionsBySession, sessionLastAccess) + } + + readSet = new Set() + readPermissionsBySession.set(sessionID, readSet) + } + + touchSession(sessionLastAccess, sessionID) + return readSet +} + +function registerReadPermission(params: { + sessionID: string + canonicalPath: string + readPermissionsBySession: Map> + sessionLastAccess: Map + maxTrackedSessions: number +}): void { + const readSet = ensureSessionReadSet(params) + if (readSet.has(params.canonicalPath)) { + readSet.delete(params.canonicalPath) + } + + readSet.add(params.canonicalPath) + trimSessionReadSet(readSet, MAX_TRACKED_PATHS_PER_SESSION) +} + +function consumeReadPermission(params: { + sessionID: string + canonicalPath: string + readPermissionsBySession: Map> + sessionLastAccess: Map +}): boolean { + const readSet = params.readPermissionsBySession.get(params.sessionID) + if (!readSet || !readSet.has(params.canonicalPath)) { + return false + } + + readSet.delete(params.canonicalPath) + touchSession(params.sessionLastAccess, params.sessionID) + return true +} + +function invalidateOtherSessions( + readPermissionsBySession: Map>, + canonicalPath: string, + writingSessionID?: string, +): void { + for (const [sessionID, readSet] of readPermissionsBySession.entries()) { + if (writingSessionID && sessionID === writingSessionID) { + continue + } + + readSet.delete(canonicalPath) + } +} + +export async function handleWriteExistingFileGuardToolExecuteBefore(params: { + ctx: PluginInput + input: { tool?: string; sessionID?: string } + output: { args?: unknown } + readPermissionsBySession: Map> + sessionLastAccess: Map + canonicalSessionRoot: string + maxTrackedSessions: number +}): Promise { + const { ctx, input, output, readPermissionsBySession, sessionLastAccess, canonicalSessionRoot, maxTrackedSessions } = params + const toolName = input.tool?.toLowerCase() + if (toolName !== "write" && toolName !== "read") { + return + } + + const argsRecord = asRecord(output.args) + const args = argsRecord as GuardArgs | undefined + const filePath = getPathFromArgs(args) + if (!filePath) { + return + } + + const resolvedPath = resolveInputPath(ctx, filePath) + const canonicalPath = toCanonicalPath(resolvedPath) + if (!isPathInsideDirectory(canonicalPath, canonicalSessionRoot)) { + return + } + + if (toolName === "read") { + if (!existsSync(resolvedPath) || !input.sessionID) { + return + } + + registerReadPermission({ + sessionID: input.sessionID, + canonicalPath, + readPermissionsBySession, + sessionLastAccess, + maxTrackedSessions, + }) + return + } + + const overwriteEnabled = isOverwriteEnabled(args?.overwrite) + if (argsRecord && "overwrite" in argsRecord) { + delete argsRecord.overwrite + } + + if (!existsSync(resolvedPath)) { + return + } + + const isSisyphusPath = canonicalPath.includes("/.sisyphus/") + if (isSisyphusPath) { + log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", { + sessionID: input.sessionID, + filePath, + }) + invalidateOtherSessions(readPermissionsBySession, canonicalPath, input.sessionID) + return + } + + if (overwriteEnabled) { + log("[write-existing-file-guard] Allowing overwrite flag bypass", { + sessionID: input.sessionID, + filePath, + resolvedPath, + }) + invalidateOtherSessions(readPermissionsBySession, canonicalPath, input.sessionID) + return + } + + if (input.sessionID && consumeReadPermission({ sessionID: input.sessionID, canonicalPath, readPermissionsBySession, sessionLastAccess })) { + log("[write-existing-file-guard] Allowing overwrite after read", { + sessionID: input.sessionID, + filePath, + resolvedPath, + }) + invalidateOtherSessions(readPermissionsBySession, canonicalPath, input.sessionID) + return + } + + log("[write-existing-file-guard] Blocking write to existing file", { + sessionID: input.sessionID, + filePath, + resolvedPath, + }) + + throw new Error("File already exists. Use edit tool instead.") +}