448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js'
|
|
import type { AppState } from 'src/state/AppState.js'
|
|
import type { Message } from 'src/types/message.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import type { AggregatedHookResult } from '../hooks.js'
|
|
import type { HookCommand } from '../settings/types.js'
|
|
import { isHookEqual } from './hooksSettings.js'
|
|
|
|
type OnHookSuccess = (
|
|
hook: HookCommand | FunctionHook,
|
|
result: AggregatedHookResult,
|
|
) => void
|
|
|
|
/** Function hook callback - returns true if check passes, false to block */
|
|
export type FunctionHookCallback = (
|
|
messages: Message[],
|
|
signal?: AbortSignal,
|
|
) => boolean | Promise<boolean>
|
|
|
|
/**
|
|
* Function hook type with callback embedded.
|
|
* Session-scoped only, cannot be persisted to settings.json.
|
|
*/
|
|
export type FunctionHook = {
|
|
type: 'function'
|
|
id?: string // Optional unique ID for removal
|
|
timeout?: number
|
|
callback: FunctionHookCallback
|
|
errorMessage: string
|
|
statusMessage?: string
|
|
}
|
|
|
|
type SessionHookMatcher = {
|
|
matcher: string
|
|
skillRoot?: string
|
|
hooks: Array<{
|
|
hook: HookCommand | FunctionHook
|
|
onHookSuccess?: OnHookSuccess
|
|
}>
|
|
}
|
|
|
|
export type SessionStore = {
|
|
hooks: {
|
|
[event in HookEvent]?: SessionHookMatcher[]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map (not Record) so .set/.delete don't change the container's identity.
|
|
* Mutator functions mutate the Map and return prev unchanged, letting
|
|
* store.ts's Object.is(next, prev) check short-circuit and skip listener
|
|
* notification. Session hooks are ephemeral per-agent runtime callbacks,
|
|
* never reactively read (only getAppState() snapshots in the query loop).
|
|
* Same pattern as agentControllers on LocalWorkflowTaskState.
|
|
*
|
|
* This matters under high-concurrency workflows: parallel() with N
|
|
* schema-mode agents fires N addFunctionHook calls in one synchronous
|
|
* tick. With a Record + spread, each call cost O(N) to copy the growing
|
|
* map (O(N²) total) plus fired all ~30 store listeners. With Map: .set()
|
|
* is O(1), return prev means zero listener fires.
|
|
*/
|
|
export type SessionHooksState = Map<string, SessionStore>
|
|
|
|
/**
|
|
* Add a command or prompt hook to the session.
|
|
* Session hooks are temporary, in-memory only, and cleared when session ends.
|
|
*/
|
|
export function addSessionHook(
|
|
setAppState: (updater: (prev: AppState) => AppState) => void,
|
|
sessionId: string,
|
|
event: HookEvent,
|
|
matcher: string,
|
|
hook: HookCommand,
|
|
onHookSuccess?: OnHookSuccess,
|
|
skillRoot?: string,
|
|
): void {
|
|
addHookToSession(
|
|
setAppState,
|
|
sessionId,
|
|
event,
|
|
matcher,
|
|
hook,
|
|
onHookSuccess,
|
|
skillRoot,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Add a function hook to the session.
|
|
* Function hooks execute TypeScript callbacks in-memory for validation.
|
|
* @returns The hook ID (for removal)
|
|
*/
|
|
export function addFunctionHook(
|
|
setAppState: (updater: (prev: AppState) => AppState) => void,
|
|
sessionId: string,
|
|
event: HookEvent,
|
|
matcher: string,
|
|
callback: FunctionHookCallback,
|
|
errorMessage: string,
|
|
options?: {
|
|
timeout?: number
|
|
id?: string
|
|
},
|
|
): string {
|
|
const id = options?.id || `function-hook-${Date.now()}-${Math.random()}`
|
|
const hook: FunctionHook = {
|
|
type: 'function',
|
|
id,
|
|
timeout: options?.timeout || 5000,
|
|
callback,
|
|
errorMessage,
|
|
}
|
|
addHookToSession(setAppState, sessionId, event, matcher, hook)
|
|
return id
|
|
}
|
|
|
|
/**
|
|
* Remove a function hook by ID from the session.
|
|
*/
|
|
export function removeFunctionHook(
|
|
setAppState: (updater: (prev: AppState) => AppState) => void,
|
|
sessionId: string,
|
|
event: HookEvent,
|
|
hookId: string,
|
|
): void {
|
|
setAppState(prev => {
|
|
const store = prev.sessionHooks.get(sessionId)
|
|
if (!store) {
|
|
return prev
|
|
}
|
|
|
|
const eventMatchers = store.hooks[event] || []
|
|
|
|
// Remove the hook with matching ID from all matchers
|
|
const updatedMatchers = eventMatchers
|
|
.map(matcher => {
|
|
const updatedHooks = matcher.hooks.filter(h => {
|
|
if (h.hook.type !== 'function') return true
|
|
return h.hook.id !== hookId
|
|
})
|
|
|
|
return updatedHooks.length > 0
|
|
? { ...matcher, hooks: updatedHooks }
|
|
: null
|
|
})
|
|
.filter((m): m is SessionHookMatcher => m !== null)
|
|
|
|
const newHooks =
|
|
updatedMatchers.length > 0
|
|
? { ...store.hooks, [event]: updatedMatchers }
|
|
: Object.fromEntries(
|
|
Object.entries(store.hooks).filter(([e]) => e !== event),
|
|
)
|
|
|
|
prev.sessionHooks.set(sessionId, { hooks: newHooks })
|
|
return prev
|
|
})
|
|
|
|
logForDebugging(
|
|
`Removed function hook ${hookId} for event ${event} in session ${sessionId}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Internal helper to add a hook to session state
|
|
*/
|
|
function addHookToSession(
|
|
setAppState: (updater: (prev: AppState) => AppState) => void,
|
|
sessionId: string,
|
|
event: HookEvent,
|
|
matcher: string,
|
|
hook: HookCommand | FunctionHook,
|
|
onHookSuccess?: OnHookSuccess,
|
|
skillRoot?: string,
|
|
): void {
|
|
setAppState(prev => {
|
|
const store = prev.sessionHooks.get(sessionId) ?? { hooks: {} }
|
|
const eventMatchers = store.hooks[event] || []
|
|
|
|
// Find existing matcher or create new one
|
|
const existingMatcherIndex = eventMatchers.findIndex(
|
|
m => m.matcher === matcher && m.skillRoot === skillRoot,
|
|
)
|
|
|
|
let updatedMatchers: SessionHookMatcher[]
|
|
if (existingMatcherIndex >= 0) {
|
|
// Add to existing matcher
|
|
updatedMatchers = [...eventMatchers]
|
|
const existingMatcher = updatedMatchers[existingMatcherIndex]!
|
|
updatedMatchers[existingMatcherIndex] = {
|
|
matcher: existingMatcher.matcher,
|
|
skillRoot: existingMatcher.skillRoot,
|
|
hooks: [...existingMatcher.hooks, { hook, onHookSuccess }],
|
|
}
|
|
} else {
|
|
// Create new matcher
|
|
updatedMatchers = [
|
|
...eventMatchers,
|
|
{
|
|
matcher,
|
|
skillRoot,
|
|
hooks: [{ hook, onHookSuccess }],
|
|
},
|
|
]
|
|
}
|
|
|
|
const newHooks = { ...store.hooks, [event]: updatedMatchers }
|
|
|
|
prev.sessionHooks.set(sessionId, { hooks: newHooks })
|
|
return prev
|
|
})
|
|
|
|
logForDebugging(
|
|
`Added session hook for event ${event} in session ${sessionId}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Remove a specific hook from the session
|
|
* @param setAppState The function to update the app state
|
|
* @param sessionId The session ID
|
|
* @param event The hook event
|
|
* @param hook The hook command to remove
|
|
*/
|
|
export function removeSessionHook(
|
|
setAppState: (updater: (prev: AppState) => AppState) => void,
|
|
sessionId: string,
|
|
event: HookEvent,
|
|
hook: HookCommand,
|
|
): void {
|
|
setAppState(prev => {
|
|
const store = prev.sessionHooks.get(sessionId)
|
|
if (!store) {
|
|
return prev
|
|
}
|
|
|
|
const eventMatchers = store.hooks[event] || []
|
|
|
|
// Remove the hook from all matchers
|
|
const updatedMatchers = eventMatchers
|
|
.map(matcher => {
|
|
const updatedHooks = matcher.hooks.filter(
|
|
h => !isHookEqual(h.hook, hook),
|
|
)
|
|
|
|
return updatedHooks.length > 0
|
|
? { ...matcher, hooks: updatedHooks }
|
|
: null
|
|
})
|
|
.filter((m): m is SessionHookMatcher => m !== null)
|
|
|
|
const newHooks =
|
|
updatedMatchers.length > 0
|
|
? { ...store.hooks, [event]: updatedMatchers }
|
|
: { ...store.hooks }
|
|
|
|
if (updatedMatchers.length === 0) {
|
|
delete newHooks[event]
|
|
}
|
|
|
|
prev.sessionHooks.set(sessionId, { ...store, hooks: newHooks })
|
|
return prev
|
|
})
|
|
|
|
logForDebugging(
|
|
`Removed session hook for event ${event} in session ${sessionId}`,
|
|
)
|
|
}
|
|
|
|
// Extended hook matcher that includes optional skillRoot for skill-scoped hooks
|
|
export type SessionDerivedHookMatcher = {
|
|
matcher: string
|
|
hooks: HookCommand[]
|
|
skillRoot?: string
|
|
}
|
|
|
|
/**
|
|
* Convert session hook matchers to regular hook matchers
|
|
* @param sessionMatchers The session hook matchers to convert
|
|
* @returns Regular hook matchers (with optional skillRoot preserved)
|
|
*/
|
|
function convertToHookMatchers(
|
|
sessionMatchers: SessionHookMatcher[],
|
|
): SessionDerivedHookMatcher[] {
|
|
return sessionMatchers.map(sm => ({
|
|
matcher: sm.matcher,
|
|
skillRoot: sm.skillRoot,
|
|
// Filter out function hooks - they can't be persisted to HookMatcher format
|
|
hooks: sm.hooks
|
|
.map(h => h.hook)
|
|
.filter((h): h is HookCommand => h.type !== 'function'),
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Get all session hooks for a specific event (excluding function hooks)
|
|
* @param appState The app state
|
|
* @param sessionId The session ID
|
|
* @param event Optional event to filter by
|
|
* @returns Hook matchers for the event, or all hooks if no event specified
|
|
*/
|
|
export function getSessionHooks(
|
|
appState: AppState,
|
|
sessionId: string,
|
|
event?: HookEvent,
|
|
): Map<HookEvent, SessionDerivedHookMatcher[]> {
|
|
const store = appState.sessionHooks.get(sessionId)
|
|
if (!store) {
|
|
return new Map()
|
|
}
|
|
|
|
const result = new Map<HookEvent, SessionDerivedHookMatcher[]>()
|
|
|
|
if (event) {
|
|
const sessionMatchers = store.hooks[event]
|
|
if (sessionMatchers) {
|
|
result.set(event, convertToHookMatchers(sessionMatchers))
|
|
}
|
|
return result
|
|
}
|
|
|
|
for (const evt of HOOK_EVENTS) {
|
|
const sessionMatchers = store.hooks[evt]
|
|
if (sessionMatchers) {
|
|
result.set(evt, convertToHookMatchers(sessionMatchers))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
type FunctionHookMatcher = {
|
|
matcher: string
|
|
hooks: FunctionHook[]
|
|
}
|
|
|
|
/**
|
|
* Get all session function hooks for a specific event
|
|
* Function hooks are kept separate because they can't be persisted to HookMatcher format.
|
|
* @param appState The app state
|
|
* @param sessionId The session ID
|
|
* @param event Optional event to filter by
|
|
* @returns Function hook matchers for the event
|
|
*/
|
|
export function getSessionFunctionHooks(
|
|
appState: AppState,
|
|
sessionId: string,
|
|
event?: HookEvent,
|
|
): Map<HookEvent, FunctionHookMatcher[]> {
|
|
const store = appState.sessionHooks.get(sessionId)
|
|
if (!store) {
|
|
return new Map()
|
|
}
|
|
|
|
const result = new Map<HookEvent, FunctionHookMatcher[]>()
|
|
|
|
const extractFunctionHooks = (
|
|
sessionMatchers: SessionHookMatcher[],
|
|
): FunctionHookMatcher[] => {
|
|
return sessionMatchers
|
|
.map(sm => ({
|
|
matcher: sm.matcher,
|
|
hooks: sm.hooks
|
|
.map(h => h.hook)
|
|
.filter((h): h is FunctionHook => h.type === 'function'),
|
|
}))
|
|
.filter(m => m.hooks.length > 0)
|
|
}
|
|
|
|
if (event) {
|
|
const sessionMatchers = store.hooks[event]
|
|
if (sessionMatchers) {
|
|
const functionMatchers = extractFunctionHooks(sessionMatchers)
|
|
if (functionMatchers.length > 0) {
|
|
result.set(event, functionMatchers)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
for (const evt of HOOK_EVENTS) {
|
|
const sessionMatchers = store.hooks[evt]
|
|
if (sessionMatchers) {
|
|
const functionMatchers = extractFunctionHooks(sessionMatchers)
|
|
if (functionMatchers.length > 0) {
|
|
result.set(evt, functionMatchers)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Get the full hook entry (including callbacks) for a specific session hook
|
|
*/
|
|
export function getSessionHookCallback(
|
|
appState: AppState,
|
|
sessionId: string,
|
|
event: HookEvent,
|
|
matcher: string,
|
|
hook: HookCommand | FunctionHook,
|
|
):
|
|
| {
|
|
hook: HookCommand | FunctionHook
|
|
onHookSuccess?: OnHookSuccess
|
|
}
|
|
| undefined {
|
|
const store = appState.sessionHooks.get(sessionId)
|
|
if (!store) {
|
|
return undefined
|
|
}
|
|
|
|
const eventMatchers = store.hooks[event]
|
|
if (!eventMatchers) {
|
|
return undefined
|
|
}
|
|
|
|
// Find the hook in the matchers
|
|
for (const matcherEntry of eventMatchers) {
|
|
if (matcherEntry.matcher === matcher || matcher === '') {
|
|
const hookEntry = matcherEntry.hooks.find(h => isHookEqual(h.hook, hook))
|
|
if (hookEntry) {
|
|
return hookEntry
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Clear all session hooks for a specific session
|
|
* @param setAppState The function to update the app state
|
|
* @param sessionId The session ID
|
|
*/
|
|
export function clearSessionHooks(
|
|
setAppState: (updater: (prev: AppState) => AppState) => void,
|
|
sessionId: string,
|
|
): void {
|
|
setAppState(prev => {
|
|
prev.sessionHooks.delete(sessionId)
|
|
return prev
|
|
})
|
|
|
|
logForDebugging(`Cleared all session hooks for session ${sessionId}`)
|
|
}
|