Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions src/hooks/runtime-fallback/auto-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,34 +42,38 @@ export function createAutoRetryHelpers(deps: HookDeps) {
if (timeoutMs <= 0) return

const timer = setTimeout(async () => {
sessionFallbackTimeouts.delete(sessionID)
try {
sessionFallbackTimeouts.delete(sessionID)

const state = sessionStates.get(sessionID)
if (!state) return
const state = sessionStates.get(sessionID)
if (!state) return

if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] Overriding in-flight retry due to session timeout`, { sessionID })
}
if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] Overriding in-flight retry due to session timeout`, { sessionID })
}

await abortSessionRequest(sessionID, "session.timeout")
sessionRetryInFlight.delete(sessionID)
await abortSessionRequest(sessionID, "session.timeout")
sessionRetryInFlight.delete(sessionID)

if (state.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
if (state.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}

const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) return
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) return

log(`[${HOOK_NAME}] Session fallback timeout reached`, {
sessionID,
timeoutSeconds: config.timeout_seconds,
currentModel: state.currentModel,
})
log(`[${HOOK_NAME}] Session fallback timeout reached`, {
sessionID,
timeoutSeconds: config.timeout_seconds,
currentModel: state.currentModel,
})

const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && result.newModel) {
await autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.timeout")
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && result.newModel) {
await autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.timeout")
}
} catch (err) {
log(`[${HOOK_NAME}] Error in session fallback timeout handler:`, { sessionID, error: String(err) })
}
}, timeoutMs)

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/session-notification-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export function createIdleNotificationScheduler(options: {
notificationVersions.set(sessionID, currentVersion)

const timer = setTimeout(() => {
executeNotification(sessionID, currentVersion)
executeNotification(sessionID, currentVersion).catch(() => {})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Silently swallowing errors can make debugging difficult. It's better to log the error to aid in troubleshooting potential issues with notifications.

You'll also need to import the log function at the top of the file:
import { log } from "../shared/logger";

Suggested change
executeNotification(sessionID, currentVersion).catch(() => {})
executeNotification(sessionID, currentVersion).catch((err) => log(`[session-notification-scheduler] Error executing notification:`, { sessionID, error: String(err) }))

}, options.config.idleConfirmationDelay)

pendingTimers.set(sessionID, timer)
Expand Down
91 changes: 54 additions & 37 deletions src/plugin/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ export function createEventHandler(args: {
const lastHandledRetryStatusKey = new Map<string, string>();
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();

const safeHookCall = async (hookName: string, fn: (() => unknown) | undefined): Promise<void> => {
if (!fn) return;
try {
await Promise.resolve(fn());
} catch (err) {
log(`[event] Hook "${hookName}" threw during dispatch:`, { error: err instanceof Error ? err.message : String(err) });
}
};

const dispatchToHooks = async (input: EventInput): Promise<void> => {
// Invalidate session cache on session events
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as string | undefined;
Expand All @@ -154,28 +163,28 @@ export function createEventHandler(args: {
}
}

await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
await Promise.resolve(hooks.sessionNotification?.(input));
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input));
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input));
await Promise.resolve(hooks.rulesInjector?.event?.(input));
await Promise.resolve(hooks.thinkMode?.event?.(input));
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input));
await Promise.resolve(hooks.runtimeFallback?.event?.(input));
await Promise.resolve(hooks.agentUsageReminder?.event?.(input));
await Promise.resolve(hooks.categorySkillReminder?.event?.(input));
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput));
await Promise.resolve(hooks.ralphLoop?.event?.(input));
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input));
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
await Promise.resolve((hooks as any).runStateWatchdog?.event?.(input));
await safeHookCall("autoUpdateChecker", () => hooks.autoUpdateChecker?.event?.(input));
await safeHookCall("claudeCodeHooks", () => hooks.claudeCodeHooks?.event?.(input));
await safeHookCall("backgroundNotificationHook", () => hooks.backgroundNotificationHook?.event?.(input));
await safeHookCall("sessionNotification", () => hooks.sessionNotification?.(input));
await safeHookCall("todoContinuationEnforcer", () => hooks.todoContinuationEnforcer?.handler?.(input));
await safeHookCall("unstableAgentBabysitter", () => hooks.unstableAgentBabysitter?.event?.(input));
await safeHookCall("contextWindowMonitor", () => hooks.contextWindowMonitor?.event?.(input));
await safeHookCall("directoryAgentsInjector", () => hooks.directoryAgentsInjector?.event?.(input));
await safeHookCall("directoryReadmeInjector", () => hooks.directoryReadmeInjector?.event?.(input));
await safeHookCall("rulesInjector", () => hooks.rulesInjector?.event?.(input));
await safeHookCall("thinkMode", () => hooks.thinkMode?.event?.(input));
await safeHookCall("anthropicContextWindowLimitRecovery", () => hooks.anthropicContextWindowLimitRecovery?.event?.(input));
await safeHookCall("runtimeFallback", () => hooks.runtimeFallback?.event?.(input));
await safeHookCall("agentUsageReminder", () => hooks.agentUsageReminder?.event?.(input));
await safeHookCall("categorySkillReminder", () => hooks.categorySkillReminder?.event?.(input));
await safeHookCall("interactiveBashSession", () => hooks.interactiveBashSession?.event?.(input as EventInput));
await safeHookCall("ralphLoop", () => hooks.ralphLoop?.event?.(input));
await safeHookCall("stopContinuationGuard", () => hooks.stopContinuationGuard?.event?.(input));
await safeHookCall("compactionTodoPreserver", () => hooks.compactionTodoPreserver?.event?.(input));
await safeHookCall("writeExistingFileGuard", () => hooks.writeExistingFileGuard?.event?.(input));
await safeHookCall("atlasHook", () => hooks.atlasHook?.handler?.(input));
await safeHookCall("runStateWatchdog", () => (hooks as any).runStateWatchdog?.event?.(input));
};

const recentSyntheticIdles = new Map<string, number>();
Expand Down Expand Up @@ -213,7 +222,11 @@ export function createEventHandler(args: {
}
}

await dispatchToHooks(input);
try {
await dispatchToHooks(input);
} catch (err) {
log("[event] dispatchToHooks failed:", { error: err instanceof Error ? err.message : String(err) });
}


const syntheticIdle = normalizeSessionStatusToIdle(input);
Expand Down Expand Up @@ -259,24 +272,28 @@ export function createEventHandler(args: {
}

if (event.type === "session.created") {
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
try {
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;

if (!sessionInfo?.parentID) {
setMainSession(sessionInfo?.id);
}
if (!sessionInfo?.parentID) {
setMainSession(sessionInfo?.id);
}

const sessionID = sessionInfo?.id;
const sessionID = sessionInfo?.id;

firstMessageVariantGate.markSessionCreated(sessionInfo);
firstMessageVariantGate.markSessionCreated(sessionInfo);

await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string };
};
},
);
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string };
};
},
);
} catch (err) {
log("[event] Error in session.created handler:", { error: err });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with other error logging in this file and to prevent potential issues with logging complex error objects, it's better to serialize the error to a string.

Suggested change
log("[event] Error in session.created handler:", { error: err });
log("[event] Error in session.created handler:", { error: String(err) });

}
}

if (event.type === "session.deleted") {
Expand Down
162 changes: 83 additions & 79 deletions src/plugin/tool-execute-before.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,99 +18,103 @@ export function createToolExecuteBeforeHandler(args: {
const { ctx, hooks } = args

return async (input, output): Promise<void> => {
await hooks.planEnforcement?.["tool.execute.before"]?.(input, output)
await hooks.semanticLoopGuard?.["tool.execute.before"]?.(input, output)
await hooks.carRuntime?.["tool.execute.before"]?.(input, output)
try {
await hooks.planEnforcement?.["tool.execute.before"]?.(input, output)
await hooks.semanticLoopGuard?.["tool.execute.before"]?.(input, output)
await hooks.carRuntime?.["tool.execute.before"]?.(input, output)

await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output)
await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output)
await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)
await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output)
await hooks.commentChecker?.["tool.execute.before"]?.(input, output)
await hooks.directoryAgentsInjector?.["tool.execute.before"]?.(input, output)
await hooks.directoryReadmeInjector?.["tool.execute.before"]?.(input, output)
await hooks.rulesInjector?.["tool.execute.before"]?.(input, output)
await hooks.tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output)
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output)
await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output)
await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)
await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output)
await hooks.commentChecker?.["tool.execute.before"]?.(input, output)
await hooks.directoryAgentsInjector?.["tool.execute.before"]?.(input, output)
await hooks.directoryReadmeInjector?.["tool.execute.before"]?.(input, output)
await hooks.rulesInjector?.["tool.execute.before"]?.(input, output)
await hooks.tasksTodowriteDisabler?.["tool.execute.before"]?.(input, output)
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)

const normalizedToolName = input.tool.toLowerCase()
if (
normalizedToolName === "question"
|| normalizedToolName === "ask_user_question"
|| normalizedToolName === "askuserquestion"
) {
const sessionID = input.sessionID || getMainSessionID()
await hooks.sessionNotification?.({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: input.tool,
args: output.args,
const normalizedToolName = input.tool.toLowerCase()
if (
normalizedToolName === "question"
|| normalizedToolName === "ask_user_question"
|| normalizedToolName === "askuserquestion"
) {
const sessionID = input.sessionID || getMainSessionID()
await hooks.sessionNotification?.({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: input.tool,
args: output.args,
},
},
},
})
}
})
}

if (input.tool === "task") {
const argsObject = output.args
const category = typeof argsObject.category === "string" ? argsObject.category : undefined
const subagentType = typeof argsObject.subagent_type === "string" ? argsObject.subagent_type : undefined
const sessionId = typeof argsObject.session_id === "string" ? argsObject.session_id : undefined
if (input.tool === "task") {
const argsObject = output.args
const category = typeof argsObject.category === "string" ? argsObject.category : undefined
const subagentType = typeof argsObject.subagent_type === "string" ? argsObject.subagent_type : undefined
const sessionId = typeof argsObject.session_id === "string" ? argsObject.session_id : undefined

if (category) {
argsObject.subagent_type = "sisyphus-junior"
} else if (!subagentType && sessionId) {
const resolvedAgent = await resolveSessionAgent(ctx.client, sessionId)
argsObject.subagent_type = resolvedAgent ?? "continue"
if (category) {
argsObject.subagent_type = "sisyphus-junior"
} else if (!subagentType && sessionId) {
const resolvedAgent = await resolveSessionAgent(ctx.client, sessionId)
argsObject.subagent_type = resolvedAgent ?? "continue"
}
}
}

if (hooks.ralphLoop && input.tool === "skill") {
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
const command = rawName?.replace(/^\//, "").toLowerCase()
const sessionID = input.sessionID || getMainSessionID()
if (hooks.ralphLoop && input.tool === "skill") {
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
const command = rawName?.replace(/^\//, "").toLowerCase()
const sessionID = input.sessionID || getMainSessionID()

if (command === "ralph-loop" && sessionID) {
const rawArgs = rawName?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
const parsedArguments = parseRalphLoopArguments(rawArgs)
if (command === "ralph-loop" && sessionID) {
const rawArgs = rawName?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
const parsedArguments = parseRalphLoopArguments(rawArgs)

hooks.ralphLoop.startLoop(sessionID, parsedArguments.prompt, {
maxIterations: parsedArguments.maxIterations,
completionPromise: parsedArguments.completionPromise,
strategy: parsedArguments.strategy,
})
} else if (command === "cancel-ralph" && sessionID) {
hooks.ralphLoop.cancelLoop(sessionID)
} else if (command === "ulw-loop" && sessionID) {
const rawArgs = rawName?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
const parsedArguments = parseRalphLoopArguments(rawArgs)
hooks.ralphLoop.startLoop(sessionID, parsedArguments.prompt, {
maxIterations: parsedArguments.maxIterations,
completionPromise: parsedArguments.completionPromise,
strategy: parsedArguments.strategy,
})
} else if (command === "cancel-ralph" && sessionID) {
hooks.ralphLoop.cancelLoop(sessionID)
} else if (command === "ulw-loop" && sessionID) {
const rawArgs = rawName?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
const parsedArguments = parseRalphLoopArguments(rawArgs)

hooks.ralphLoop.startLoop(sessionID, parsedArguments.prompt, {
ultrawork: true,
maxIterations: parsedArguments.maxIterations,
completionPromise: parsedArguments.completionPromise,
strategy: parsedArguments.strategy,
})
hooks.ralphLoop.startLoop(sessionID, parsedArguments.prompt, {
ultrawork: true,
maxIterations: parsedArguments.maxIterations,
completionPromise: parsedArguments.completionPromise,
strategy: parsedArguments.strategy,
})
}
}
}

if (input.tool === "skill") {
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
const command = rawName?.replace(/^\//, "").toLowerCase()
const sessionID = input.sessionID || getMainSessionID()
if (input.tool === "skill") {
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
const command = rawName?.replace(/^\//, "").toLowerCase()
const sessionID = input.sessionID || getMainSessionID()

if (command === "stop-continuation" && sessionID) {
hooks.stopContinuationGuard?.stop(sessionID)
hooks.todoContinuationEnforcer?.cancelAllCountdowns()
hooks.ralphLoop?.cancelLoop(sessionID)
clearBoulderState(ctx.directory)
log("[stop-continuation] All continuation mechanisms stopped", {
sessionID,
})
if (command === "stop-continuation" && sessionID) {
hooks.stopContinuationGuard?.stop(sessionID)
hooks.todoContinuationEnforcer?.cancelAllCountdowns()
hooks.ralphLoop?.cancelLoop(sessionID)
clearBoulderState(ctx.directory)
log("[stop-continuation] All continuation mechanisms stopped", {
sessionID,
})
}
}
} catch (err: unknown) {
log("[tool-execute-before.ts] Unhandled hook error caught:", { error: err instanceof Error ? err.message : String(err) })
}
Comment on lines +116 to 118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

As noted in the PR description, this catch block swallows all errors, which might not be desirable for safety-critical hooks like planEnforcement and semanticLoopGuard. To avoid unintentionally suppressing important failures, consider re-throwing errors that are deemed critical, similar to the pattern used in tool-execute-after.ts.

For example:

} catch (err: unknown) {
  if (isSafetyCriticalHookError(err)) { // `isSafetyCriticalHookError` would need to be defined/imported
    throw err;
  }
  log("[tool-execute-before.ts] Unhandled hook error caught:", { error: err instanceof Error ? err.message : String(err) })
}

}
}
Loading