refactor(hooks): fix empty catches and remove AI slop from code comments
This commit is contained in:
@@ -11,6 +11,27 @@ import type { Client } from "./client"
|
|||||||
import { PLACEHOLDER_TEXT } from "./message-builder"
|
import { PLACEHOLDER_TEXT } from "./message-builder"
|
||||||
import { incrementEmptyContentAttempt } from "./state"
|
import { incrementEmptyContentAttempt } from "./state"
|
||||||
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
|
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<void> {
|
||||||
|
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: {
|
export async function fixEmptyMessages(params: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -32,30 +53,30 @@ export async function fixEmptyMessages(params: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!result.fixed && result.scannedEmptyCount === 0) {
|
if (!result.fixed && result.scannedEmptyCount === 0) {
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Empty Content Error",
|
title: "Empty Content Error",
|
||||||
message: "No empty messages found in storage. Cannot auto-recover.",
|
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
})
|
"sqlite empty message not found",
|
||||||
.catch(() => {})
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.fixed) {
|
if (result.fixed) {
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Session Recovery",
|
title: "Session Recovery",
|
||||||
message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`,
|
message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`,
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
"sqlite empty message fixed",
|
||||||
.catch(() => {})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.fixed
|
return result.fixed
|
||||||
@@ -83,16 +104,16 @@ export async function fixEmptyMessages(params: {
|
|||||||
const emptyTextPartIds = findMessagesWithEmptyTextParts(params.sessionID)
|
const emptyTextPartIds = findMessagesWithEmptyTextParts(params.sessionID)
|
||||||
const allIds = [...new Set([...emptyMessageIds, ...emptyTextPartIds])]
|
const allIds = [...new Set([...emptyMessageIds, ...emptyTextPartIds])]
|
||||||
if (allIds.length === 0) {
|
if (allIds.length === 0) {
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Empty Content Error",
|
title: "Empty Content Error",
|
||||||
message: "No empty messages found in storage. Cannot auto-recover.",
|
message: "No empty messages found in storage. Cannot auto-recover.",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
})
|
"empty message not found",
|
||||||
.catch(() => {})
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,16 +133,16 @@ export async function fixEmptyMessages(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Session Recovery",
|
title: "Session Recovery",
|
||||||
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
|
message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
"empty messages fixed",
|
||||||
.catch(() => {})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fixed
|
return fixed
|
||||||
|
|||||||
@@ -13,8 +13,32 @@ import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
|
|||||||
import { fixEmptyMessages } from "./empty-content-recovery"
|
import { fixEmptyMessages } from "./empty-content-recovery"
|
||||||
|
|
||||||
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
|
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
const SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS = 120_000
|
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<void> {
|
||||||
|
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: {
|
export async function runSummarizeRetryStrategy(params: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
msg: Record<string, unknown>
|
msg: Record<string, unknown>
|
||||||
@@ -40,16 +64,16 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
const elapsedTimeMs = now - retryState.firstAttemptTime
|
const elapsedTimeMs = now - retryState.firstAttemptTime
|
||||||
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
|
if (elapsedTimeMs >= SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS) {
|
||||||
clearSessionState(params.autoCompactState, params.sessionID)
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Auto Compact Timed Out",
|
title: "Auto Compact Timed Out",
|
||||||
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
})
|
"retry timeout",
|
||||||
.catch(() => {})
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +98,17 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clearSessionState(params.autoCompactState, params.sessionID)
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Recovery Failed",
|
title: "Recovery Failed",
|
||||||
message:
|
message:
|
||||||
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
"Max recovery attempts (3) reached for empty content error. Please start a new session.",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
},
|
},
|
||||||
})
|
"empty content recovery exhausted",
|
||||||
.catch(() => {})
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,16 +130,16 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
try {
|
try {
|
||||||
await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client)
|
await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client)
|
||||||
|
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Auto Compact",
|
title: "Auto Compact",
|
||||||
message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`,
|
message: `Summarizing session (attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts})...`,
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
"summarize retry attempt",
|
||||||
.catch(() => {})
|
)
|
||||||
|
|
||||||
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
||||||
params.pluginConfig,
|
params.pluginConfig,
|
||||||
@@ -132,20 +156,26 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
})
|
})
|
||||||
clearSessionState(params.autoCompactState, params.sessionID)
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
return
|
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)
|
const remainingTimeMs = SUMMARIZE_RETRY_TOTAL_TIMEOUT_MS - (Date.now() - retryState.firstAttemptTime)
|
||||||
if (remainingTimeMs <= 0) {
|
if (remainingTimeMs <= 0) {
|
||||||
clearSessionState(params.autoCompactState, params.sessionID)
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Auto Compact Timed Out",
|
title: "Auto Compact Timed Out",
|
||||||
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
message: "Compaction retries exceeded the timeout window. Please start a new session.",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
})
|
"summarize retry timeout after failure",
|
||||||
.catch(() => {})
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,28 +192,28 @@ export async function runSummarizeRetryStrategy(params: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Summarize Skipped",
|
title: "Summarize Skipped",
|
||||||
message: "Missing providerID or modelID.",
|
message: "Missing providerID or modelID.",
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
},
|
},
|
||||||
})
|
"missing summarize model info",
|
||||||
.catch(() => {})
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSessionState(params.autoCompactState, params.sessionID)
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
await params.client.tui
|
await showToastSafely(
|
||||||
.showToast({
|
params.client,
|
||||||
body: {
|
{
|
||||||
title: "Auto Compact Failed",
|
title: "Auto Compact Failed",
|
||||||
message: "All recovery attempts failed. Please start a new session.",
|
message: "All recovery attempts failed. Please start a new session.",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
})
|
"summarize retry failed",
|
||||||
.catch(() => {})
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>
|
||||||
|
onApplied?: (input: {
|
||||||
|
sessionID: string
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
variant?: string
|
||||||
|
}) => void | Promise<void>
|
||||||
|
lastToastKey: Map<string, string>
|
||||||
|
}): Promise<void> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import { readConnectedProvidersCache, readProviderModelsCache } from "../../shar
|
|||||||
import { selectFallbackProvider } from "../../shared/model-error-classifier"
|
import { selectFallbackProvider } from "../../shared/model-error-classifier"
|
||||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
|
||||||
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
|
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
|
||||||
|
import { applyFallbackToChatMessage } from "./chat-message-fallback-handler"
|
||||||
|
import { getNextReachableFallback } from "./next-fallback"
|
||||||
|
|
||||||
type FallbackToast = (input: {
|
type FallbackToast = (input: {
|
||||||
title: string
|
title: string
|
||||||
@@ -39,12 +40,6 @@ const pendingModelFallbacks = new Map<string, ModelFallbackState>()
|
|||||||
const lastToastKey = new Map<string, string>()
|
const lastToastKey = new Map<string, string>()
|
||||||
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
|
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
|
||||||
|
|
||||||
function canonicalizeModelID(modelID: string): string {
|
|
||||||
return modelID
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\./g, "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (!fallbackChain || fallbackChain.length === 0) {
|
if (!fallbackChain || fallbackChain.length === 0) {
|
||||||
@@ -126,58 +121,9 @@ export function getNextFallback(
|
|||||||
|
|
||||||
if (!state.pending) return null
|
if (!state.pending) return null
|
||||||
|
|
||||||
const { fallbackChain } = state
|
const fallback = getNextReachableFallback(sessionID, state)
|
||||||
|
if (fallback) {
|
||||||
const providerModelsCache = readProviderModelsCache()
|
return fallback
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log("[model-fallback] No more fallbacks for session: " + sessionID)
|
log("[model-fallback] No more fallbacks for session: " + sessionID)
|
||||||
@@ -227,50 +173,14 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
|
|||||||
const fallback = getNextFallback(sessionID)
|
const fallback = getNextFallback(sessionID)
|
||||||
if (!fallback) return
|
if (!fallback) return
|
||||||
|
|
||||||
output.message["model"] = {
|
await applyFallbackToChatMessage({
|
||||||
providerID: fallback.providerID,
|
input,
|
||||||
modelID: fallback.modelID,
|
output,
|
||||||
}
|
fallback,
|
||||||
if (fallback.variant !== undefined) {
|
toast,
|
||||||
output.message["variant"] = fallback.variant
|
onApplied,
|
||||||
} else {
|
lastToastKey,
|
||||||
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))
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||||
|
|
||||||
if (sessionRetryInFlight.has(sessionID)) {
|
if (sessionRetryInFlight.has(sessionID)) {
|
||||||
log(`[${HOOK_NAME}] session.error skipped — retry in flight`, {
|
log(`[${HOOK_NAME}] session.error skipped - retry in flight`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
retryInFlight: true,
|
retryInFlight: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function getFallbackModelsForSession(
|
|||||||
/**
|
/**
|
||||||
* Returns the raw fallback model entries (strings and objects) for a session.
|
* Returns the raw fallback model entries (strings and objects) for a session.
|
||||||
* Use this when per-model settings (temperature, reasoningEffort, etc.) must be
|
* 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(
|
export function getRawFallbackModels(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function createSessionStatusHandler(
|
|||||||
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
|
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
|
||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
} else {
|
} 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function sendSessionNotification(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case "darwin": {
|
case "darwin": {
|
||||||
// Try terminal-notifier first — deterministic click-to-focus
|
// Try terminal-notifier first - deterministic click-to-focus
|
||||||
const terminalNotifierPath = await getTerminalNotifierPath()
|
const terminalNotifierPath = await getTerminalNotifierPath()
|
||||||
if (terminalNotifierPath) {
|
if (terminalNotifierPath) {
|
||||||
const bundleId = process.env.__CFBundleIdentifier
|
const bundleId = process.env.__CFBundleIdentifier
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
|
import { log } from "../shared/logger"
|
||||||
|
|
||||||
|
declare const Bun: {
|
||||||
|
which(commandName: string): string | null
|
||||||
|
}
|
||||||
|
|
||||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||||
|
|
||||||
async function findCommand(commandName: string): Promise<string | null> {
|
async function findCommand(commandName: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return Bun.which(commandName)
|
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
|
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<string | null> {
|
function createCommandFinder(commandName: string): () => Promise<string | null> {
|
||||||
let cachedPath: string | null = null
|
let cachedPath: string | null = null
|
||||||
let pending: Promise<string | null> | null = null
|
let pending: Promise<string | null> | null = null
|
||||||
@@ -36,14 +53,28 @@ export const getTerminalNotifierPath = createCommandFinder("terminal-notifier")
|
|||||||
|
|
||||||
export function startBackgroundCheck(platform: Platform): void {
|
export function startBackgroundCheck(platform: Platform): void {
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
getOsascriptPath().catch(() => {})
|
getOsascriptPath().catch((error) => {
|
||||||
getAfplayPath().catch(() => {})
|
logBackgroundCheckError("osascript", error)
|
||||||
getTerminalNotifierPath().catch(() => {})
|
})
|
||||||
|
getAfplayPath().catch((error) => {
|
||||||
|
logBackgroundCheckError("afplay", error)
|
||||||
|
})
|
||||||
|
getTerminalNotifierPath().catch((error) => {
|
||||||
|
logBackgroundCheckError("terminal-notifier", error)
|
||||||
|
})
|
||||||
} else if (platform === "linux") {
|
} else if (platform === "linux") {
|
||||||
getNotifySendPath().catch(() => {})
|
getNotifySendPath().catch((error) => {
|
||||||
getPaplayPath().catch(() => {})
|
logBackgroundCheckError("notify-send", error)
|
||||||
getAplayPath().catch(() => {})
|
})
|
||||||
|
getPaplayPath().catch((error) => {
|
||||||
|
logBackgroundCheckError("paplay", error)
|
||||||
|
})
|
||||||
|
getAplayPath().catch((error) => {
|
||||||
|
logBackgroundCheckError("aplay", error)
|
||||||
|
})
|
||||||
} else if (platform === "win32") {
|
} else if (platform === "win32") {
|
||||||
getPowershellPath().catch(() => {})
|
getPowershellPath().catch((error) => {
|
||||||
|
logBackgroundCheckError("powershell", error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof readBoulderState>
|
||||||
|
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<ReturnType<typeof readBoulderState>>
|
||||||
|
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<typeof readBoulderState>,
|
||||||
|
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 + `
|
||||||
|
|
||||||
|
<system-reminder>
|
||||||
|
## 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}
|
||||||
|
</system-reminder>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStartWorkContextInfo(params: {
|
||||||
|
ctx: PluginInput
|
||||||
|
explicitPlanName: string | null
|
||||||
|
existingState: ReturnType<typeof readBoulderState>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "../../features/claude-code-session-state"
|
} from "../../features/claude-code-session-state"
|
||||||
import { detectWorktreePath } from "./worktree-detector"
|
import { detectWorktreePath } from "./worktree-detector"
|
||||||
import { parseUserRequest } from "./parse-user-request"
|
import { parseUserRequest } from "./parse-user-request"
|
||||||
|
import { buildStartWorkContextInfo } from "./context-info-builder"
|
||||||
|
|
||||||
export const HOOK_NAME = "start-work" as const
|
export const HOOK_NAME = "start-work" as const
|
||||||
const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session."
|
const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session."
|
||||||
@@ -43,24 +44,16 @@ interface StartWorkHookOutput {
|
|||||||
parts: Array<{ type: string; text?: string }>
|
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 {
|
function createWorktreeActiveBlock(worktreePath: string): string {
|
||||||
return `
|
return `
|
||||||
## Worktree Active
|
## Worktree Active
|
||||||
|
|
||||||
**Worktree**: \`${worktreePath}\`
|
**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}\`
|
- 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
|
- 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(
|
function resolveWorktreeContext(
|
||||||
@@ -129,174 +122,16 @@ export function createStartWorkHook(ctx: PluginInput) {
|
|||||||
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
|
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
|
||||||
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
|
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
|
||||||
|
|
||||||
let contextInfo = ""
|
const contextInfo = buildStartWorkContextInfo({
|
||||||
|
ctx,
|
||||||
if (explicitPlanName) {
|
explicitPlanName,
|
||||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
|
existingState,
|
||||||
|
sessionId,
|
||||||
const allPlans = findPrometheusPlans(ctx.directory)
|
timestamp,
|
||||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
activeAgent,
|
||||||
|
worktreePath,
|
||||||
if (matchedPlan) {
|
worktreeBlock,
|
||||||
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 += `
|
|
||||||
|
|
||||||
<system-reminder>
|
|
||||||
## 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}
|
|
||||||
</system-reminder>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||||
if (idx >= 0 && output.parts[idx].text) {
|
if (idx >= 0 && output.parts[idx].text) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
|
|||||||
* Check if there are any Anthropic-signed thinking blocks in the message history.
|
* 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`.
|
* 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.
|
* 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.)
|
* 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
|
* Returns the original Part object (including its `signature` field) so it can
|
||||||
* be reused verbatim in another message. Only `type: "thinking"` blocks with
|
* 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
|
* blocks are excluded because they lack an Anthropic signature and would be
|
||||||
* rejected by the API with "Invalid `signature` in `thinking` block".
|
* rejected by the API with "Invalid `signature` in `thinking` block".
|
||||||
* Synthetic parts injected by a previous run of this hook are also skipped.
|
* 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
|
if (!msg.parts) continue
|
||||||
|
|
||||||
for (const part of msg.parts) {
|
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
|
if (!isSignedThinkingPart(part)) continue
|
||||||
|
|
||||||
return part
|
return part
|
||||||
@@ -145,10 +145,10 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip if there are no Anthropic-signed thinking blocks in history.
|
// 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
|
// GPT with thinking variants, or any future model. Crucially, GPT
|
||||||
// reasoning blocks (type="reasoning", no signature) do NOT trigger this
|
// 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)) {
|
if (!hasSignedThinkingBlocksInHistory(messages)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
|||||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||||
// Find the most recent real thinking part (with valid signature) from
|
// Find the most recent real thinking part (with valid signature) from
|
||||||
// previous turns. If none exists we cannot safely inject a thinking
|
// 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".
|
// to reject the request with "Invalid `signature` in `thinking` block".
|
||||||
const previousThinkingPart = findPreviousThinkingPart(messages, i)
|
const previousThinkingPart = findPreviousThinkingPart(messages, i)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ GOOD:
|
|||||||
|
|
||||||
BAD:
|
BAD:
|
||||||
- "Implement email validation" (where? how? what result?)
|
- "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?)
|
- "Fix auth" (what file? what changes? what's expected?)
|
||||||
|
|
||||||
## Granularity Rules
|
## Granularity Rules
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { existsSync, realpathSync } from "fs"
|
|||||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||||
|
|
||||||
import { log } from "../../shared"
|
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
|
filePath?: string
|
||||||
path?: string
|
path?: string
|
||||||
file_path?: string
|
file_path?: string
|
||||||
@@ -16,7 +18,7 @@ const MAX_TRACKED_SESSIONS = 256
|
|||||||
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
|
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
|
||||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -24,22 +26,22 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPathFromArgs(args: GuardArgs | undefined): string | undefined {
|
export function getPathFromArgs(args: GuardArgs | undefined): string | undefined {
|
||||||
return args?.filePath ?? args?.path ?? args?.file_path
|
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))
|
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)
|
const relativePath = relative(directory, pathToCheck)
|
||||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function toCanonicalPath(absolutePath: string): string {
|
export function toCanonicalPath(absolutePath: string): string {
|
||||||
let canonicalPath = absolutePath
|
let canonicalPath = absolutePath
|
||||||
|
|
||||||
if (existsSync(absolutePath)) {
|
if (existsSync(absolutePath)) {
|
||||||
@@ -59,7 +61,7 @@ function toCanonicalPath(absolutePath: string): string {
|
|||||||
return normalize(canonicalPath)
|
return normalize(canonicalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOverwriteEnabled(value: boolean | string | undefined): boolean {
|
export function isOverwriteEnabled(value: boolean | string | undefined): boolean {
|
||||||
if (value === true) {
|
if (value === true) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -76,165 +78,17 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
|||||||
const sessionLastAccess = new Map<string, number>()
|
const sessionLastAccess = new Map<string, number>()
|
||||||
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
|
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<string> => {
|
|
||||||
let readSet = readPermissionsBySession.get(sessionID)
|
|
||||||
if (!readSet) {
|
|
||||||
if (readPermissionsBySession.size >= MAX_TRACKED_SESSIONS) {
|
|
||||||
evictLeastRecentlyUsedSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
readSet = new Set<string>()
|
|
||||||
readPermissionsBySession.set(sessionID, readSet)
|
|
||||||
}
|
|
||||||
|
|
||||||
touchSession(sessionID)
|
|
||||||
return readSet
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimSessionReadSet = (readSet: Set<string>): 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 {
|
return {
|
||||||
"tool.execute.before": async (input, output) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
const toolName = input.tool?.toLowerCase()
|
await handleWriteExistingFileGuardToolExecuteBefore({
|
||||||
if (toolName !== "write" && toolName !== "read") {
|
ctx,
|
||||||
return
|
input,
|
||||||
}
|
output,
|
||||||
|
readPermissionsBySession,
|
||||||
const argsRecord = asRecord(output.args)
|
sessionLastAccess,
|
||||||
const args = argsRecord as GuardArgs | undefined
|
canonicalSessionRoot,
|
||||||
const filePath = getPathFromArgs(args)
|
maxTrackedSessions: MAX_TRACKED_SESSIONS,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
throw new Error("File already exists. Use edit tool instead.")
|
|
||||||
},
|
},
|
||||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
if (event.type !== "session.deleted") {
|
if (event.type !== "session.deleted") {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export function touchSession(sessionLastAccess: Map<string, number>, sessionID: string): void {
|
||||||
|
sessionLastAccess.set(sessionID, Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evictLeastRecentlyUsedSession(
|
||||||
|
readPermissionsBySession: Map<string, Set<string>>,
|
||||||
|
sessionLastAccess: Map<string, number>,
|
||||||
|
): 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<string>, maxTrackedPathsPerSession: number): void {
|
||||||
|
while (readSet.size > maxTrackedPathsPerSession) {
|
||||||
|
const oldestPath = readSet.values().next().value
|
||||||
|
if (!oldestPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
readSet.delete(oldestPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, Set<string>>
|
||||||
|
sessionLastAccess: Map<string, number>
|
||||||
|
maxTrackedSessions: number
|
||||||
|
}): Set<string> {
|
||||||
|
const { sessionID, readPermissionsBySession, sessionLastAccess, maxTrackedSessions } = params
|
||||||
|
let readSet = readPermissionsBySession.get(sessionID)
|
||||||
|
if (!readSet) {
|
||||||
|
if (readPermissionsBySession.size >= maxTrackedSessions) {
|
||||||
|
evictLeastRecentlyUsedSession(readPermissionsBySession, sessionLastAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
readSet = new Set<string>()
|
||||||
|
readPermissionsBySession.set(sessionID, readSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
touchSession(sessionLastAccess, sessionID)
|
||||||
|
return readSet
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerReadPermission(params: {
|
||||||
|
sessionID: string
|
||||||
|
canonicalPath: string
|
||||||
|
readPermissionsBySession: Map<string, Set<string>>
|
||||||
|
sessionLastAccess: Map<string, number>
|
||||||
|
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<string, Set<string>>
|
||||||
|
sessionLastAccess: Map<string, number>
|
||||||
|
}): 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<string, Set<string>>,
|
||||||
|
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<string, Set<string>>
|
||||||
|
sessionLastAccess: Map<string, number>
|
||||||
|
canonicalSessionRoot: string
|
||||||
|
maxTrackedSessions: number
|
||||||
|
}): Promise<void> {
|
||||||
|
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.")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user