🪝 feat: Add Claude Hook Compatibility Layer (#14412)

* feat: add Claude hook compatibility layer

* fix: satisfy API declaration build

* fix: normalize hook matchers and conditions

* fix: align Claude hook lifecycle semantics

* fix: preserve Claude hook execution semantics

* fix: gate unsupported Claude hook controls

* fix: refine Claude payload and matcher translation

* fix: scope Claude hook conditions to tool events

* fix: preserve mixed Claude hook documents

* fix: honor Claude hook execution controls

* fix: close Claude hook lifecycle gaps

* fix: isolate Claude hook declaration state

* fix: preserve Claude matcher semantics

* fix: handle PreemptBoundary in plugin hook payload switch

`PreemptBoundary` joined `HOOK_EVENTS` in @librechat/agents 3.4.2. It has no
Claude counterpart and is absent from the compatibility EVENT_MAP, so it can
never reach a registered declaration, but the payload switch must stay
exhaustive so a future event fails the build rather than returning undefined.
This commit is contained in:
Danny Avila 2026-08-08 07:13:11 -04:00 • committed by GitHub
parent 39f5f9d846
commit 493ffced46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 3138 additions and 0 deletions

View file

@ -0,0 +1,649 @@
import type { PluginHookCapabilities } from './compatibility';
import type { PluginHooksDocument } from './schema';
import { planPluginHooks } from './compatibility';
const commandCapabilities: PluginHookCapabilities = {
handlerTypes: new Set(['command']),
translateMatcher: ({ matcher }: { matcher: string }) => matcher,
};
function document(hooks: PluginHooksDocument['hooks']): PluginHooksDocument {
return { hooks };
}
describe('planPluginHooks', () => {
test('maps the common Claude lifecycle events directly', () => {
const plan = planPluginHooks(
document({
PreToolUse: [{ matcher: '^write_file$', hooks: [{ type: 'command', command: 'check' }] }],
PostToolUse: [{ hooks: [{ type: 'command', command: 'record' }] }],
Stop: [{ matcher: '.*', hooks: [{ type: 'command', command: 'verify' }] }],
}),
commandCapabilities,
);
expect(plan.summary).toEqual({ declared: 3, ready: 3, unsupported: 0 });
expect(
plan.entries.map(({ sourceEvent, targetEvent, matcher }) => ({
sourceEvent,
targetEvent,
matcher,
})),
).toEqual([
{ sourceEvent: 'PreToolUse', targetEvent: 'PreToolUse', matcher: '^write_file$' },
{ sourceEvent: 'PostToolUse', targetEvent: 'PostToolUse', matcher: undefined },
{ sourceEvent: 'Stop', targetEvent: 'Stop', matcher: undefined },
]);
});
test('maps SessionStart to RunStart with an explicit lifecycle warning', () => {
const plan = planPluginHooks(
document({
SessionStart: [
{ matcher: 'resume', hooks: [{ type: 'command', command: 'load-context' }] },
],
}),
{ handlerTypes: new Set(['command']), sessionLifecycle: true },
);
expect(plan.summary.ready).toBe(1);
expect(plan.entries[0]).toEqual(
expect.objectContaining({
sourceEvent: 'SessionStart',
targetEvent: 'RunStart',
sourceMatcher: 'resume',
matcher: '^(?:resume)$',
status: 'ready',
issues: [
expect.objectContaining({
code: 'event_alias',
severity: 'warning',
}),
],
}),
);
});
test('normalizes Claude exact and list matchers while preserving regex matchers', () => {
const translatedMatchers: string[] = [];
const plan = planPluginHooks(
document({
PreToolUse: [
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'pipe-list' }] },
{ matcher: 'Edit, Write', hooks: [{ type: 'command', command: 'comma-list' }] },
{
matcher: 'mcp__.*__write.*',
hooks: [{ type: 'command', command: 'regex-pattern' }],
},
],
SessionStart: [
{ matcher: 'startup, resume', hooks: [{ type: 'command', command: 'load-context' }] },
],
SubagentStart: [
{ matcher: 'code-reviewer', hooks: [{ type: 'command', command: 'track-agent' }] },
],
StopFailure: [
{
matcher: 'rate_limit, overloaded',
hooks: [{ type: 'command', command: 'track-failure' }],
},
],
}),
{
...commandCapabilities,
sessionLifecycle: true,
translateMatcher: ({ matcher }) => {
translatedMatchers.push(matcher);
return matcher;
},
},
);
expect(plan.summary).toEqual({ declared: 6, ready: 6, unsupported: 0 });
expect(plan.entries.map(({ sourceMatcher, matcher }) => ({ sourceMatcher, matcher }))).toEqual([
{ sourceMatcher: 'Edit|Write', matcher: '^(?:Edit|Write)$' },
{ sourceMatcher: 'Edit, Write', matcher: '^(?:Edit|Write)$' },
{ sourceMatcher: 'mcp__.*__write.*', matcher: 'mcp__.*__write.*' },
{ sourceMatcher: 'startup, resume', matcher: '^(?:startup|resume)$' },
{ sourceMatcher: 'code-reviewer', matcher: '^(?:code-reviewer)$' },
{ sourceMatcher: 'rate_limit, overloaded', matcher: 'rate_limit, overloaded' },
]);
expect(translatedMatchers).toEqual([
'Edit|Write',
'Edit|Write',
'mcp__.*__write.*',
'code-reviewer',
'rate_limit, overloaded',
]);
});
test('fails closed for SessionStart compact matchers', () => {
const plan = planPluginHooks(
document({
SessionStart: [
{ matcher: 'compact', hooks: [{ type: 'command', command: 'reload-context' }] },
],
}),
{ handlerTypes: new Set(['command']), sessionLifecycle: true },
);
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
expect(plan.entries[0]).toEqual(
expect.objectContaining({
sourceEvent: 'SessionStart',
targetEvent: 'RunStart',
status: 'unsupported',
issues: expect.arrayContaining([
expect.objectContaining({
code: 'unsupported_session_source',
severity: 'error',
}),
]),
}),
);
});
test('keeps wildcard SessionStart ready while reporting compact as filtered', () => {
const plan = planPluginHooks(
document({
SessionStart: [{ hooks: [{ type: 'command', command: 'load-context' }] }],
}),
{ handlerTypes: new Set(['command']), sessionLifecycle: true },
);
expect(plan.summary).toEqual({ declared: 1, ready: 1, unsupported: 0 });
expect(plan.entries[0].issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'unsupported_session_source',
severity: 'warning',
}),
]),
);
});
test('plans translated compaction-trigger matchers', () => {
const plan = planPluginHooks(
document({
PreCompact: [{ matcher: 'auto', hooks: [{ type: 'command', command: 'before' }] }],
PostCompact: [{ matcher: 'manual', hooks: [{ type: 'command', command: 'after' }] }],
}),
{
handlerTypes: new Set(['command']),
translateMatcher: ({ matcher }) =>
matcher === 'auto'
? '^(token_ratio|remaining_tokens|messages_to_refine|default)$'
: '^manual$',
},
);
expect(plan.summary).toEqual({ declared: 2, ready: 2, unsupported: 0 });
expect(plan.entries).toEqual([
expect.objectContaining({
sourceEvent: 'PreCompact',
sourceMatcher: 'auto',
matcher: '^(?:^(token_ratio|remaining_tokens|messages_to_refine|default)$)$',
status: 'ready',
}),
expect.objectContaining({
sourceEvent: 'PostCompact',
sourceMatcher: 'manual',
matcher: '^(?:^manual$)$',
status: 'ready',
}),
]);
});
test('plans StopFailure matchers against translated error names', () => {
const plan = planPluginHooks(
document({
StopFailure: [
{
matcher: 'rate_limit|overloaded',
hooks: [{ type: 'command', command: 'record-failure' }],
},
],
}),
commandCapabilities,
);
expect(plan.summary).toEqual({ declared: 1, ready: 1, unsupported: 0 });
expect(plan.entries[0]).toEqual(
expect.objectContaining({
sourceEvent: 'StopFailure',
targetEvent: 'StopFailure',
sourceMatcher: 'rate_limit|overloaded',
matcher: '^(?:rate_limit|overloaded)$',
status: 'ready',
}),
);
});
test('does not activate events whose Claude control surfaces are unavailable', () => {
const plan = planPluginHooks(
document({
SubagentStop: [{ hooks: [{ type: 'command', command: 'verify' }] }],
PermissionDenied: [{ hooks: [{ type: 'command', command: 'retry' }] }],
}),
commandCapabilities,
);
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
expect(plan.entries[0]).toEqual(
expect.objectContaining({
sourceEvent: 'SubagentStop',
targetEvent: 'SubagentStop',
status: 'unsupported',
issues: [
expect.objectContaining({
code: 'unsupported_event_payload',
severity: 'error',
}),
],
}),
);
expect(plan.entries[1]).toEqual(
expect.objectContaining({
sourceEvent: 'PermissionDenied',
targetEvent: 'PermissionDenied',
status: 'unsupported',
issues: [
expect.objectContaining({
code: 'unsupported_event_output',
severity: 'error',
}),
],
}),
);
});
test('does not silently activate events or handler types the runtime cannot execute', () => {
const plan = planPluginHooks(
document({
UserPromptExpansion: [{ hooks: [{ type: 'command', command: 'banner' }] }],
Stop: [{ hooks: [{ type: 'prompt', prompt: 'Verify completion' }] }],
}),
commandCapabilities,
);
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
expect(plan.entries[0].issues).toEqual([
expect.objectContaining({ code: 'unsupported_event' }),
]);
expect(plan.entries[1].issues).toEqual([
expect.objectContaining({ code: 'unsupported_handler' }),
]);
});
test('keeps known unsupported handler declarations in a mixed compatibility plan', () => {
const plan = planPluginHooks(
document({
Stop: [
{
hooks: [
{ type: 'command', command: 'verify' },
{ type: 'http', url: 'https://hooks.example.com/stop' },
{ type: 'mcp_tool', server: 'policy', tool: 'validate', input: { strict: true } },
{ type: 'agent', prompt: 'Review completion', model: 'claude-sonnet-4-5' },
],
},
],
}),
commandCapabilities,
);
expect(plan.summary).toEqual({ declared: 4, ready: 1, unsupported: 3 });
expect(plan.entries[0].status).toBe('ready');
expect(plan.entries.slice(1)).toEqual(
expect.arrayContaining([
expect.objectContaining({
handler: expect.objectContaining({ type: 'http' }),
status: 'unsupported',
issues: [expect.objectContaining({ code: 'unsupported_handler' })],
}),
expect.objectContaining({
handler: expect.objectContaining({ type: 'mcp_tool' }),
status: 'unsupported',
issues: [expect.objectContaining({ code: 'unsupported_handler' })],
}),
expect.objectContaining({
handler: expect.objectContaining({ type: 'agent' }),
status: 'unsupported',
issues: [expect.objectContaining({ code: 'unsupported_handler' })],
}),
]),
);
});
test('preserves continueOnBlock for supported prompt handlers', () => {
const plan = planPluginHooks(
document({
PostToolUse: [
{
hooks: [
{
type: 'prompt',
prompt: 'Review this tool result',
continueOnBlock: true,
},
],
},
],
PreToolUse: [
{
hooks: [
{
type: 'prompt',
prompt: 'Verify this tool call',
continueOnBlock: true,
},
],
},
],
}),
{
...commandCapabilities,
handlerTypes: new Set(['command', 'prompt']),
},
);
expect(plan.summary).toEqual({ declared: 2, ready: 2, unsupported: 0 });
expect(plan.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: 'ready',
handler: expect.objectContaining({ continueOnBlock: true }),
}),
]),
);
});
test('rejects continueOnBlock on non-prompt handlers', () => {
const plan = planPluginHooks(
document({
PostToolUse: [
{
hooks: [{ type: 'command', command: 'verify-output', continueOnBlock: true }],
},
],
}),
commandCapabilities,
);
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
expect(plan.entries[0].issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'unsupported_continue_on_block',
severity: 'error',
}),
]),
);
});
test('rejects prompt handlers on events that do not support them', () => {
const plan = planPluginHooks(
document({
SessionStart: [
{
matcher: 'startup',
hooks: [{ type: 'prompt', prompt: 'Load context' }],
},
],
StopFailure: [{ hooks: [{ type: 'prompt', prompt: 'Classify the failure' }] }],
}),
{
handlerTypes: new Set(['command', 'prompt']),
sessionLifecycle: true,
},
);
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
for (const entry of plan.entries) {
expect(entry.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'unsupported_handler_event',
severity: 'error',
}),
]),
);
}
});
test('rejects conditional, async-rewake, and unsafe matcher semantics by default', () => {
const plan = planPluginHooks(
document({
RunStart: [
{
matcher: 'startup',
hooks: [{ type: 'command', command: 'load' }],
},
],
PostToolUse: [
{
matcher: '(a+)+',
hooks: [
{
type: 'command',
command: 'review',
if: 'Bash(git commit:*)',
async: true,
asyncRewake: true,
},
],
},
],
}),
commandCapabilities,
);
expect(plan.summary.unsupported).toBe(2);
expect(plan.entries[0].issues).toEqual([
expect.objectContaining({ code: 'unsupported_matcher' }),
]);
expect(plan.entries[1].issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_matcher' }),
expect.objectContaining({ code: 'unsupported_condition' }),
expect.objectContaining({ code: 'unsupported_async' }),
expect.objectContaining({ code: 'unsupported_async_rewake' }),
]),
);
});
test('requires explicit matcher and session-lifecycle adapters', () => {
const plan = planPluginHooks(
document({
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'check' }] }],
SessionStart: [{ hooks: [{ type: 'command', command: 'load' }] }],
}),
{ handlerTypes: new Set(['command']) },
);
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
expect(plan.entries[0].issues).toEqual([expect.objectContaining({ code: 'unmapped_matcher' })]);
expect(plan.entries[1].issues).toEqual([
expect.objectContaining({ code: 'unsupported_session_lifecycle' }),
]);
});
test('records matcher translation without losing the source declaration', () => {
const plan = planPluginHooks(
document({
PreToolUse: [{ matcher: ' Bash|Write ', hooks: [{ type: 'command', command: 'check' }] }],
}),
{
handlerTypes: new Set(['command']),
translateMatcher: () => '^(bash_tool|create_file)$',
toPluginToolName: ({ toolName }) => toolName,
},
);
expect(plan.entries[0]).toEqual(
expect.objectContaining({
sourceMatcher: 'Bash|Write',
matcher: '^(?:^(bash_tool|create_file)$)$',
status: 'ready',
issues: [expect.objectContaining({ code: 'matcher_translated', severity: 'warning' })],
}),
);
});
test('keeps invalid authored regex matchers unsupported after a valid translation', () => {
const plan = planPluginHooks(
document({
PreToolUse: [{ matcher: '[invalid', hooks: [{ type: 'command', command: 'check' }] }],
}),
{
handlerTypes: new Set(['command']),
translateMatcher: () => '^bash_tool$',
},
);
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
expect(plan.entries[0]).toEqual(
expect.objectContaining({
sourceMatcher: '[invalid',
matcher: '^bash_tool$',
status: 'unsupported',
issues: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_matcher', severity: 'error' }),
expect.objectContaining({ code: 'matcher_translated', severity: 'warning' }),
]),
}),
);
});
test('requires reverse tool-name translation when matcher namespaces differ', () => {
const plan = planPluginHooks(
document({
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'check' }] }],
}),
{
handlerTypes: new Set(['command']),
translateMatcher: () => ({
matcher: '^bash_tool$',
requiresToolNameTranslation: true,
}),
},
);
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
expect(plan.entries[0].issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'matcher_translated', severity: 'warning' }),
expect.objectContaining({ code: 'unmapped_tool_name', severity: 'error' }),
]),
);
});
test('allows regex-only matcher rewrites without reverse tool-name mapping', () => {
const plan = planPluginHooks(
document({
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'check' }] }],
}),
{
handlerTypes: new Set(['command']),
translateMatcher: () => '^Bash$',
},
);
expect(plan.summary).toEqual({ declared: 1, ready: 1, unsupported: 0 });
expect(plan.entries[0]).toEqual(
expect.objectContaining({
matcher: '^(?:^Bash$)$',
status: 'ready',
issues: [expect.objectContaining({ code: 'matcher_translated', severity: 'warning' })],
}),
);
});
test('plans current handler-level conditions independently', () => {
const plan = planPluginHooks(
document({
PostToolUse: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'review-commit',
if: 'Bash(git commit:*)',
},
{
type: 'command',
command: 'record-all',
},
],
},
],
}),
{
handlerTypes: new Set(['command']),
translateMatcher: () => '^bash_tool$',
toPluginToolName: ({ toolName }) => toolName,
},
);
expect(plan.summary).toEqual({ declared: 2, ready: 1, unsupported: 1 });
expect(plan.entries[0]).toEqual(
expect.objectContaining({
condition: 'Bash(git commit:*)',
status: 'unsupported',
issues: expect.arrayContaining([
expect.objectContaining({ code: 'unsupported_condition' }),
]),
}),
);
expect(plan.entries[1]).toEqual(
expect.objectContaining({
status: 'ready',
}),
);
});
test('rejects conditional expressions on non-tool events', () => {
const plan = planPluginHooks(
document({
Stop: [
{
hooks: [
{
type: 'command',
command: 'verify',
if: 'Bash(git status:*)',
},
],
},
],
}),
{
handlerTypes: new Set(['command']),
matchCondition: () => true,
},
);
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
expect(plan.entries[0].issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'unsupported_condition',
severity: 'error',
message: 'Conditional `if` hook expressions are only supported for tool events',
}),
]),
);
});
test('converts portable timeout seconds to the SDK timeout in milliseconds', () => {
const plan = planPluginHooks(
document({
Stop: [{ hooks: [{ type: 'command', command: 'verify', timeout: 30 }] }],
}),
commandCapabilities,
);
expect(plan.entries[0].timeoutMs).toBe(30_000);
});
});

View file

@ -0,0 +1,544 @@
import { MAX_PATTERN_LENGTH, hasNestedQuantifier } from '@librechat/agents';
import type { HookEvent } from '@librechat/agents';
import type { PluginHookHandler, PluginHooksDocument } from './schema';
const EVENT_MAP = new Map<string, HookEvent>([
['RunStart', 'RunStart'],
['SessionStart', 'RunStart'],
['UserPromptSubmit', 'UserPromptSubmit'],
['PreToolUse', 'PreToolUse'],
['PostToolUse', 'PostToolUse'],
['PostToolUseFailure', 'PostToolUseFailure'],
['PostToolBatch', 'PostToolBatch'],
['PermissionDenied', 'PermissionDenied'],
['SubagentStart', 'SubagentStart'],
['SubagentStop', 'SubagentStop'],
['Stop', 'Stop'],
['StopFailure', 'StopFailure'],
['PreCompact', 'PreCompact'],
['PostCompact', 'PostCompact'],
]);
const QUERY_EVENTS = new Set<HookEvent>([
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionDenied',
'SubagentStart',
'SubagentStop',
'StopFailure',
'PreCompact',
'PostCompact',
]);
const TOOL_NAME_EVENTS = new Set<HookEvent>([
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionDenied',
]);
const GENERAL_EXACT_MATCHER = /^[-A-Za-z0-9_,| ]+$/;
const NARROW_EXACT_MATCHER = /^[A-Za-z0-9_|]+$/;
const NARROW_EXACT_MATCHER_EVENTS = new Set(['FileChanged', 'StopFailure']);
export type PluginHookIssueSeverity = 'warning' | 'error';
export type PluginHookIssueCode =
| 'event_alias'
| 'unsupported_event'
| 'unsupported_handler'
| 'invalid_matcher'
| 'matcher_translated'
| 'unmapped_matcher'
| 'unmapped_tool_name'
| 'unsupported_matcher'
| 'unsupported_condition'
| 'conflicting_condition'
| 'unsupported_async'
| 'unsupported_async_rewake'
| 'unsupported_continue_on_block'
| 'unsupported_handler_event'
| 'unsupported_session_lifecycle'
| 'unsupported_session_source'
| 'unsupported_event_payload'
| 'unsupported_event_output'
| 'long_timeout';
export interface PluginHookCompatibilityIssue {
code: PluginHookIssueCode;
severity: PluginHookIssueSeverity;
message: string;
}
export type PluginHookHandlerType = 'command' | 'prompt';
export interface PluginHookMatcherTranslation {
sourceEvent: string;
targetEvent: HookEvent;
/** Authored regex, or a canonical pipe-separated list when Claude applies exact matching. */
matcher: string;
}
export interface PluginHookMatcherTranslationResult {
/** Runtime pattern; translations of Claude exact matchers are whole-string anchored. */
matcher: string;
requiresToolNameTranslation?: boolean;
}
export interface PluginHookToolNameTranslation {
sourceEvent: string;
targetEvent: HookEvent;
toolName: string;
}
export interface PluginHookConditionMatch {
sourceEvent: string;
targetEvent: HookEvent;
condition: string;
toolName: string;
toolInput: Record<string, unknown>;
}
export interface PluginHookCapabilities {
handlerTypes: ReadonlySet<PluginHookHandlerType>;
translateMatcher?: (
input: PluginHookMatcherTranslation,
) => string | PluginHookMatcherTranslationResult | undefined;
/** Maps a LibreChat runtime tool name back into the plugin's source namespace. */
toPluginToolName?: (input: PluginHookToolNameTranslation) => string;
/** Evaluates Claude permission-rule syntax before a conditional handler executes. */
matchCondition?: (input: PluginHookConditionMatch) => boolean;
async?: boolean;
asyncRewake?: boolean;
sessionLifecycle?: boolean;
}
export interface PluginHookPlanEntry {
sourceEvent: string;
targetEvent?: HookEvent;
groupIndex: number;
handlerIndex: number;
sourceMatcher?: string;
matcher?: string;
condition?: string;
timeoutMs?: number;
handler: PluginHookHandler;
status: 'ready' | 'unsupported';
issues: PluginHookCompatibilityIssue[];
}
export interface PluginHookPlanSummary {
declared: number;
ready: number;
unsupported: number;
}
export interface PluginHookPlan {
description?: string;
entries: PluginHookPlanEntry[];
summary: PluginHookPlanSummary;
}
function normalizeMatcher(matcher: string | undefined): string | undefined {
const trimmed = matcher?.trim();
if (!trimmed || trimmed === '*' || trimmed === '.*') {
return undefined;
}
return trimmed;
}
type ClaudeMatcherSemantics =
| { kind: 'exact'; canonicalMatcher: string; values: string[] }
| { kind: 'regex'; canonicalMatcher: string };
function getClaudeMatcherSemantics(sourceEvent: string, matcher: string): ClaudeMatcherSemantics {
const isNarrowEvent = NARROW_EXACT_MATCHER_EVENTS.has(sourceEvent);
const exactMatcher = isNarrowEvent ? NARROW_EXACT_MATCHER : GENERAL_EXACT_MATCHER;
if (!exactMatcher.test(matcher)) {
return { kind: 'regex', canonicalMatcher: matcher };
}
const values = matcher.split(isNarrowEvent ? /\|/ : /[|,]/).map((value) => value.trim());
return {
kind: 'exact',
canonicalMatcher: values.join('|'),
values,
};
}
function anchorExactMatcher(matcher: string): string {
return `^(?:${matcher})$`;
}
function matcherIncludesValue(matcher: string, value: string): boolean {
try {
return new RegExp(matcher).test(value);
} catch {
return false;
}
}
function getMatcherValidationIssue(
sourceEvent: string,
matcher: string | undefined,
targetEvent: HookEvent | undefined,
): PluginHookCompatibilityIssue | undefined {
if (!matcher) {
return undefined;
}
if (targetEvent && !QUERY_EVENTS.has(targetEvent) && sourceEvent !== 'SessionStart') {
return {
code: 'unsupported_matcher',
severity: 'error',
message: `${targetEvent} does not expose a matcher query in the LibreChat hook runtime`,
};
}
if (matcher.length > MAX_PATTERN_LENGTH || hasNestedQuantifier(matcher)) {
return {
code: 'invalid_matcher',
severity: 'error',
message: 'Matcher exceeds the safe regex limits enforced by the LibreChat hook runtime',
};
}
try {
void new RegExp(matcher);
return undefined;
} catch {
return {
code: 'invalid_matcher',
severity: 'error',
message: 'Matcher is not a valid regular expression',
};
}
}
function getEventIssues(
sourceEvent: string,
targetEvent: HookEvent | undefined,
capabilities: PluginHookCapabilities,
): PluginHookCompatibilityIssue[] {
if (!targetEvent) {
return [
{
code: 'unsupported_event',
severity: 'error',
message: `${sourceEvent} has no equivalent LibreChat lifecycle event`,
},
];
}
if (sourceEvent === 'SubagentStop') {
return [
{
code: 'unsupported_event_payload',
severity: 'error',
message:
'SubagentStop is unavailable because the LibreChat hook input does not expose stop-hook state',
},
];
}
if (sourceEvent === 'PermissionDenied') {
return [
{
code: 'unsupported_event_output',
severity: 'error',
message:
'PermissionDenied is unavailable because the LibreChat hook output cannot request a retry',
},
];
}
if (sourceEvent !== 'SessionStart') {
return [];
}
if (capabilities.sessionLifecycle !== true) {
return [
{
code: 'unsupported_session_lifecycle',
severity: 'error',
message: 'SessionStart requires a runtime that deduplicates RunStart by plugin session',
},
];
}
return [
{
code: 'event_alias',
severity: 'warning',
message: 'SessionStart maps to RunStart with runtime-provided once-per-session semantics',
},
];
}
const PROMPT_UNSUPPORTED_EVENTS = new Set([
'SessionStart',
'PermissionDenied',
'SubagentStart',
'StopFailure',
'PreCompact',
'PostCompact',
]);
function getHandlerIssues(
sourceEvent: string,
handler: PluginHookHandler,
capabilities: PluginHookCapabilities,
): PluginHookCompatibilityIssue[] {
const issues: PluginHookCompatibilityIssue[] = [];
const supportedHandlerType =
handler.type === 'command' || handler.type === 'prompt' ? handler.type : undefined;
if (!supportedHandlerType || !capabilities.handlerTypes.has(supportedHandlerType)) {
issues.push({
code: 'unsupported_handler',
severity: 'error',
message: `The configured executor does not support ${handler.type} hook handlers`,
});
}
if (handler.type === 'prompt' && PROMPT_UNSUPPORTED_EVENTS.has(sourceEvent)) {
issues.push({
code: 'unsupported_handler_event',
severity: 'error',
message: `${sourceEvent} does not support prompt hook handlers`,
});
}
if (handler.async === true && capabilities.async !== true) {
issues.push({
code: 'unsupported_async',
severity: 'error',
message: 'The configured executor does not support asynchronous hook execution',
});
}
if (handler.asyncRewake === true && capabilities.asyncRewake !== true) {
issues.push({
code: 'unsupported_async_rewake',
severity: 'error',
message: 'The configured executor does not support asyncRewake',
});
}
if (handler.continueOnBlock === true && handler.type !== 'prompt') {
issues.push({
code: 'unsupported_continue_on_block',
severity: 'error',
message: 'continueOnBlock is only supported for prompt hook handlers',
});
}
if ((handler.timeout ?? 0) > 600) {
issues.push({
code: 'long_timeout',
severity: 'warning',
message: 'Hook timeout exceeds the portable 600-second compatibility target',
});
}
return issues;
}
interface MatcherPlan {
sourceMatcher?: string;
matcher?: string;
issues: PluginHookCompatibilityIssue[];
}
function planMatcher(
sourceEvent: string,
targetEvent: HookEvent | undefined,
configuredMatcher: string | undefined,
capabilities: PluginHookCapabilities,
): MatcherPlan {
const sourceMatcher = normalizeMatcher(configuredMatcher);
if (!sourceMatcher) {
if (sourceEvent === 'SessionStart' && capabilities.sessionLifecycle === true) {
return {
issues: [
{
code: 'unsupported_session_source',
severity: 'warning',
message:
'Wildcard SessionStart compatibility covers startup, resume, and clear; compact is runtime-filtered',
},
],
};
}
return { issues: [] };
}
const matcherSemantics = getClaudeMatcherSemantics(sourceEvent, sourceMatcher);
const validationIssue = getMatcherValidationIssue(sourceEvent, sourceMatcher, targetEvent);
if (validationIssue?.code === 'unsupported_matcher' || !targetEvent) {
return {
sourceMatcher,
matcher: sourceMatcher,
issues: validationIssue ? [validationIssue] : [],
};
}
if (sourceEvent === 'SessionStart') {
const matcher =
matcherSemantics.kind === 'exact'
? anchorExactMatcher(matcherSemantics.canonicalMatcher)
: sourceMatcher;
const runtimeValidationIssue =
validationIssue ?? getMatcherValidationIssue(sourceEvent, matcher, targetEvent);
const includesCompact =
matcherSemantics.kind === 'exact'
? matcherSemantics.values.includes('compact')
: matcherIncludesValue(matcher, 'compact');
if (!runtimeValidationIssue && includesCompact) {
return {
sourceMatcher,
matcher,
issues: [
{
code: 'unsupported_session_source',
severity: 'error',
message:
'SessionStart source "compact" is unavailable because LibreChat PostCompact hook output cannot inject session context',
},
],
};
}
return {
sourceMatcher,
matcher,
issues: runtimeValidationIssue ? [runtimeValidationIssue] : [],
};
}
if (!capabilities.translateMatcher) {
return {
sourceMatcher,
issues: [
{
code: 'unmapped_matcher',
severity: 'error',
message: 'Plugin matcher namespaces require an explicit LibreChat query translation',
},
],
};
}
let translation: string | PluginHookMatcherTranslationResult | undefined;
try {
translation = capabilities.translateMatcher({
sourceEvent,
targetEvent,
matcher: matcherSemantics.canonicalMatcher,
});
} catch {
translation = undefined;
}
const translatedMatcher = typeof translation === 'string' ? translation : translation?.matcher;
const requiresToolNameTranslation =
typeof translation === 'object' && translation.requiresToolNameTranslation === true;
if (!translatedMatcher?.trim()) {
return {
sourceMatcher,
issues: [
{
code: 'unmapped_matcher',
severity: 'error',
message: 'The configured matcher translator could not map this plugin matcher',
},
],
};
}
const normalizedTranslation = translatedMatcher.trim();
const matcher =
matcherSemantics.kind === 'exact'
? anchorExactMatcher(normalizedTranslation)
: normalizedTranslation;
const issues: PluginHookCompatibilityIssue[] = validationIssue ? [validationIssue] : [];
const translatedValidationIssue = getMatcherValidationIssue(sourceEvent, matcher, targetEvent);
if (translatedValidationIssue && !validationIssue) {
issues.push(translatedValidationIssue);
}
if (normalizedTranslation !== matcherSemantics.canonicalMatcher) {
issues.push({
code: 'matcher_translated',
severity: 'warning',
message: `Plugin matcher "${sourceMatcher}" maps to LibreChat matcher "${matcher}"`,
});
}
if (
requiresToolNameTranslation &&
TOOL_NAME_EVENTS.has(targetEvent) &&
!capabilities.toPluginToolName
) {
issues.push({
code: 'unmapped_tool_name',
severity: 'error',
message: 'Translated tool matchers require a reverse tool-name mapping for plugin payloads',
});
}
return { sourceMatcher, matcher, issues };
}
function hasError(issues: readonly PluginHookCompatibilityIssue[]): boolean {
return issues.some((issue) => issue.severity === 'error');
}
export function planPluginHooks(
document: PluginHooksDocument,
capabilities: PluginHookCapabilities,
): PluginHookPlan {
const entries: PluginHookPlanEntry[] = [];
const summary: PluginHookPlanSummary = { declared: 0, ready: 0, unsupported: 0 };
for (const [sourceEvent, groups] of Object.entries(document.hooks)) {
const targetEvent = EVENT_MAP.get(sourceEvent);
const eventIssues = getEventIssues(sourceEvent, targetEvent, capabilities);
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const group = groups[groupIndex];
const matcherPlan = planMatcher(sourceEvent, targetEvent, group.matcher, capabilities);
const groupIssues = [...eventIssues, ...matcherPlan.issues];
for (let handlerIndex = 0; handlerIndex < group.hooks.length; handlerIndex++) {
const handler = group.hooks[handlerIndex];
const issues = [...groupIssues, ...getHandlerIssues(sourceEvent, handler, capabilities)];
const condition = handler.if ?? group.if;
if (handler.if && group.if && handler.if !== group.if) {
issues.push({
code: 'conflicting_condition',
severity: 'error',
message: 'A hook cannot combine different group-level and handler-level conditions',
});
}
if (condition) {
if (!targetEvent || !TOOL_NAME_EVENTS.has(targetEvent)) {
issues.push({
code: 'unsupported_condition',
severity: 'error',
message: 'Conditional `if` hook expressions are only supported for tool events',
});
} else if (!capabilities.matchCondition) {
issues.push({
code: 'unsupported_condition',
severity: 'error',
message: 'The configured executor does not support conditional `if` hook expressions',
});
}
}
const status = hasError(issues) ? 'unsupported' : 'ready';
entries.push({
sourceEvent,
targetEvent,
groupIndex,
handlerIndex,
...(matcherPlan.sourceMatcher !== undefined && {
sourceMatcher: matcherPlan.sourceMatcher,
}),
...(matcherPlan.matcher !== undefined && { matcher: matcherPlan.matcher }),
...(condition !== undefined && { condition }),
handler,
status,
issues,
...(handler.timeout !== undefined && { timeoutMs: handler.timeout * 1_000 }),
});
summary.declared++;
summary[status === 'ready' ? 'ready' : 'unsupported']++;
}
}
}
return {
...(document.description !== undefined && { description: document.description }),
entries,
summary,
};
}

View file

@ -0,0 +1,3 @@
export * from './schema';
export * from './runtime';
export * from './compatibility';

View file

@ -0,0 +1,968 @@
import { HookRegistry, executeHooks } from '@librechat/agents';
import { AIMessage } from '@librechat/agents/langchain/messages';
import type { HookOutput } from '@librechat/agents';
import type { PluginHookCapabilities } from './compatibility';
import type { PluginHookExecutor } from './runtime';
import type { PluginHooksDocument } from './schema';
import { createPluginHookPayload, registerPluginHooks } from './runtime';
const commandCapabilities: PluginHookCapabilities = {
handlerTypes: new Set(['command']),
translateMatcher: ({ matcher }: { matcher: string }) => matcher,
};
function document(hooks: PluginHooksDocument['hooks']): PluginHooksDocument {
return { hooks };
}
function executor(output: HookOutput = {}): PluginHookExecutor & {
execute: jest.Mock<Promise<HookOutput>, Parameters<PluginHookExecutor['execute']>>;
} {
const execute = jest.fn<Promise<HookOutput>, Parameters<PluginHookExecutor['execute']>>(
async (_request, _signal) => output,
);
return {
capabilities: {
...commandCapabilities,
handlerTypes: new Set(commandCapabilities.handlerTypes),
},
execute,
};
}
describe('registerPluginHooks', () => {
test('registers a supported command hook and passes a Claude-shaped payload', async () => {
const registry = new HookRegistry();
const hookExecutor = executor({
decision: 'deny',
reason: 'Protected path',
additionalContext: 'Policy checked',
});
hookExecutor.capabilities.matchCondition = ({ toolInput }) =>
typeof toolInput.path === 'string' && toolInput.path.startsWith('/workspace/');
const registration = registerPluginHooks({
pluginId: 'security-guidance',
registry,
executor: hookExecutor,
context: {
sessionId: 'conversation-1',
cwd: '/workspace',
permissionMode: 'default',
},
document: document({
PreToolUse: [
{
matcher: '^write_file$',
hooks: [
{
type: 'command',
command: 'check-write',
if: 'Write(/workspace/**)',
timeout: 5,
once: true,
},
],
},
],
}),
});
expect(registration.registered).toBe(1);
expect(registry.getMatchers('PreToolUse')[0].timeout).toBe(5_000);
expect(registry.getMatchers('PreToolUse')[0].once).toBeUndefined();
const result = await executeHooks({
registry,
matchQuery: 'write_file',
input: {
hook_event_name: 'PreToolUse',
runId: 'run-1',
threadId: 'thread-1',
agentId: 'agent-1',
toolName: 'write_file',
toolInput: { path: '/workspace/file.ts' },
toolUseId: 'tool-1',
},
});
expect(result).toEqual(
expect.objectContaining({
decision: 'deny',
reason: 'Protected path',
additionalContexts: ['Policy checked'],
}),
);
expect(hookExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: 'security-guidance',
sourceEvent: 'PreToolUse',
targetEvent: 'PreToolUse',
condition: 'Write(/workspace/**)',
payload: expect.objectContaining({
hook_event_name: 'PreToolUse',
session_id: 'conversation-1',
run_id: 'run-1',
thread_id: 'thread-1',
cwd: '/workspace',
permission_mode: 'default',
agent_id: 'agent-1',
tool_name: 'write_file',
tool_input: { path: '/workspace/file.ts' },
tool_use_id: 'tool-1',
}),
}),
expect.any(AbortSignal),
);
});
test('registers Claude exact and list matchers without substring matches', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
const registration = registerPluginHooks({
pluginId: 'exact-tool-matches',
registry,
executor: hookExecutor,
document: document({
PreToolUse: [
{
matcher: 'Edit|Write',
hooks: [{ type: 'command', command: 'pipe-list-audit' }],
},
{
matcher: 'Edit, Write',
hooks: [{ type: 'command', command: 'comma-list-audit' }],
},
],
}),
});
const run = async (runId: string, toolName: string): Promise<void> => {
await executeHooks({
registry,
matchQuery: toolName,
input: {
hook_event_name: 'PreToolUse',
runId,
toolName,
toolInput: {},
toolUseId: `tool-${runId}`,
},
});
};
await run('substring', 'MultiEdit');
expect(hookExecutor.execute).not.toHaveBeenCalled();
await run('exact', 'Edit');
expect(registration.plan.entries.map(({ matcher }) => matcher)).toEqual([
'^(?:Edit|Write)$',
'^(?:Edit|Write)$',
]);
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
});
test('applies Claude default timeouts when handlers omit them', () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.handlerTypes = new Set(['command', 'prompt']);
registerPluginHooks({
pluginId: 'timeout-defaults',
registry,
executor: hookExecutor,
document: document({
Stop: [
{
hooks: [
{ type: 'command', command: 'long-running-check' },
{ type: 'prompt', prompt: 'Verify completion' },
],
},
],
UserPromptSubmit: [
{
hooks: [{ type: 'command', command: 'validate-prompt' }],
},
],
}),
});
expect(registry.getMatchers('Stop').map(({ timeout }) => timeout)).toEqual([600_000, 30_000]);
expect(registry.getMatchers('UserPromptSubmit')[0].timeout).toBe(30_000);
});
test('maps LibreChat tool names back into the plugin payload namespace', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.translateMatcher = () => ({
matcher: '^bash_tool$',
requiresToolNameTranslation: true,
});
hookExecutor.capabilities.toPluginToolName = ({ toolName }) =>
toolName === 'bash_tool' ? 'Bash' : toolName;
registerPluginHooks({
pluginId: 'tool-name-adapter',
registry,
executor: hookExecutor,
document: document({
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'check-bash' }],
},
],
}),
});
await executeHooks({
registry,
matchQuery: 'bash_tool',
input: {
hook_event_name: 'PreToolUse',
runId: 'run-tool-name',
toolName: 'bash_tool',
toolInput: { command: 'npm test' },
toolUseId: 'tool-name-1',
},
});
expect(hookExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({ toolName: 'bash_tool' }),
payload: expect.objectContaining({ tool_name: 'Bash' }),
}),
expect.any(AbortSignal),
);
});
test('does not consume a conditional once hook before its condition matches', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.translateMatcher = () => ({
matcher: '^bash_tool$',
requiresToolNameTranslation: true,
});
hookExecutor.capabilities.toPluginToolName = ({ toolName }) =>
toolName === 'bash_tool' ? 'Bash' : toolName;
hookExecutor.capabilities.matchCondition = ({ toolName, toolInput }) =>
toolName === 'Bash' &&
typeof toolInput.command === 'string' &&
toolInput.command.startsWith('git commit ');
registerPluginHooks({
pluginId: 'commit-review',
registry,
executor: hookExecutor,
document: document({
PreToolUse: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'review-commit',
if: 'Bash(git commit:*)',
once: true,
},
],
},
],
}),
});
expect(registry.getMatchers('PreToolUse')[0].once).toBeUndefined();
const run = async (runId: string, command: string): Promise<void> => {
await executeHooks({
registry,
matchQuery: 'bash_tool',
input: {
hook_event_name: 'PreToolUse',
runId,
threadId: 'commit-session',
toolName: 'bash_tool',
toolInput: { command },
toolUseId: `tool-${runId}`,
},
});
};
await run('test', 'npm test');
await run('commit', 'git commit -m "test"');
await run('second-commit', 'git commit -m "again"');
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
expect(hookExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
condition: 'Bash(git commit:*)',
input: expect.objectContaining({ runId: 'commit' }),
}),
expect.any(AbortSignal),
);
});
test('scopes once handlers to each plugin session', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
registerPluginHooks({
pluginId: 'session-once',
registry,
executor: hookExecutor,
document: document({
PreToolUse: [
{
matcher: 'Write',
hooks: [{ type: 'command', command: 'initialize-write-audit', once: true }],
},
],
}),
});
const run = async (runId: string, threadId: string): Promise<void> => {
await executeHooks({
registry,
matchQuery: 'Write',
input: {
hook_event_name: 'PreToolUse',
runId,
threadId,
toolName: 'Write',
toolInput: { file_path: '/workspace/file.ts' },
toolUseId: `tool-${runId}`,
},
});
};
await run('session-a-first', 'session-a');
await run('session-a-second', 'session-a');
await run('session-b-first', 'session-b');
expect(registry.getMatchers('PreToolUse')[0].once).toBeUndefined();
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
expect(hookExecutor.execute.mock.calls.map(([request]) => request.input.threadId)).toEqual([
'session-a',
'session-b',
]);
});
test('shares once state across duplicate overlapping handlers', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
const duplicateHandler = {
type: 'command',
command: 'initialize-write-audit',
once: true,
};
registerPluginHooks({
pluginId: 'deduplicated-session-once',
registry,
executor: hookExecutor,
document: document({
PreToolUse: [
{ matcher: '^Write$', hooks: [duplicateHandler] },
{ matcher: '^Wri.*$', hooks: [{ ...duplicateHandler }] },
],
}),
});
const run = async (runId: string): Promise<void> => {
await executeHooks({
registry,
matchQuery: 'Write',
input: {
hook_event_name: 'PreToolUse',
runId,
threadId: 'shared-once-session',
toolName: 'Write',
toolInput: { file_path: '/workspace/file.ts' },
toolUseId: `tool-${runId}`,
},
});
};
await run('first');
await run('second');
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
});
test('keeps once state separate across disjoint matcher declarations', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
const duplicateHandler = {
type: 'command',
command: 'initialize-tool-audit',
once: true,
};
registerPluginHooks({
pluginId: 'separate-session-once',
registry,
executor: hookExecutor,
document: document({
PreToolUse: [
{ matcher: '^Write$', hooks: [duplicateHandler] },
{ matcher: '^Bash$', hooks: [{ ...duplicateHandler }] },
],
}),
});
const run = async (runId: string, toolName: string): Promise<void> => {
await executeHooks({
registry,
matchQuery: toolName,
input: {
hook_event_name: 'PreToolUse',
runId,
threadId: 'separate-once-session',
toolName,
toolInput: {},
toolUseId: `tool-${runId}`,
},
});
};
await run('write-first', 'Write');
await run('write-second', 'Write');
await run('bash-first', 'Bash');
await run('bash-second', 'Bash');
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
});
test('passes prompt continueOnBlock through to the executor', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.handlerTypes = new Set(['command', 'prompt']);
registerPluginHooks({
pluginId: 'self-correcting-review',
registry,
executor: hookExecutor,
document: document({
PostToolUse: [
{
matcher: 'Write',
hooks: [
{
type: 'prompt',
prompt: 'Verify this write result',
continueOnBlock: true,
},
],
},
],
}),
});
await executeHooks({
registry,
matchQuery: 'Write',
input: {
hook_event_name: 'PostToolUse',
runId: 'run-write',
toolName: 'Write',
toolInput: { file_path: '/workspace/file.ts' },
toolOutput: 'updated',
toolUseId: 'tool-write',
},
});
expect(hookExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
handler: expect.objectContaining({ continueOnBlock: true }),
}),
expect.any(AbortSignal),
);
});
test('deduplicates identical handlers across overlapping matcher groups', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
const duplicateHandler = { type: 'command', command: 'audit-write' };
const registration = registerPluginHooks({
pluginId: 'deduplicated-hooks',
registry,
executor: hookExecutor,
document: document({
PreToolUse: [
{ matcher: '^write_file$', hooks: [duplicateHandler] },
{ matcher: '^write_.*$', hooks: [{ ...duplicateHandler }] },
],
}),
});
await executeHooks({
registry,
matchQuery: 'write_file',
input: {
hook_event_name: 'PreToolUse',
runId: 'run-deduplicate',
toolName: 'write_file',
toolInput: { path: '/workspace/file.ts' },
toolUseId: 'tool-deduplicate',
},
});
expect(registration.registered).toBe(2);
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
});
test('filters and deduplicates SessionStart while registering RunStart', async () => {
const registry = new HookRegistry();
const hookExecutor = executor({ additionalContext: 'Loaded project context' });
hookExecutor.capabilities.sessionLifecycle = true;
delete hookExecutor.capabilities.translateMatcher;
registerPluginHooks({
pluginId: 'learning-output-style',
registry,
executor: hookExecutor,
context: {
sessionStartSource: 'resume',
model: 'claude-sonnet-4-6',
agentType: 'code-reviewer',
},
document: document({
SessionStart: [
{
matcher: 'resume',
hooks: [{ type: 'command', command: 'load-context' }],
},
{
matcher: 'startup',
hooks: [{ type: 'command', command: 'do-not-load' }],
},
],
}),
});
const firstResult = await executeHooks({
registry,
input: {
hook_event_name: 'RunStart',
runId: 'run-2',
threadId: 'conversation-2',
messages: [],
},
});
await executeHooks({
registry,
input: {
hook_event_name: 'RunStart',
runId: 'run-3',
threadId: 'conversation-2',
messages: [],
},
});
expect(firstResult.additionalContexts).toEqual(['Loaded project context']);
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
expect(hookExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
sourceEvent: 'SessionStart',
targetEvent: 'RunStart',
handler: expect.objectContaining({ command: 'load-context' }),
payload: expect.objectContaining({
hook_event_name: 'SessionStart',
session_id: 'conversation-2',
source: 'resume',
model: 'claude-sonnet-4-6',
agent_type: 'code-reviewer',
}),
}),
expect.any(AbortSignal),
);
});
test('runtime-filters compact from wildcard SessionStart hooks', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.sessionLifecycle = true;
registerPluginHooks({
pluginId: 'compact-context',
registry,
executor: hookExecutor,
context: { sessionStartSource: 'compact', model: 'claude-sonnet-4-6' },
document: document({
SessionStart: [{ hooks: [{ type: 'command', command: 'load-context' }] }],
}),
});
await executeHooks({
registry,
input: {
hook_event_name: 'RunStart',
runId: 'run-compact-session',
threadId: 'conversation-compact-session',
messages: [],
},
});
expect(hookExecutor.execute).not.toHaveBeenCalled();
});
test('shares SessionStart deduplication across overlapping declarations', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.sessionLifecycle = true;
delete hookExecutor.capabilities.translateMatcher;
const duplicateHandler = { type: 'command', command: 'load-context' };
registerPluginHooks({
pluginId: 'deduplicated-session-start',
registry,
executor: hookExecutor,
context: { sessionStartSource: 'resume' },
document: document({
SessionStart: [
{ matcher: 'startup|resume', hooks: [duplicateHandler] },
{ matcher: 'resume', hooks: [{ ...duplicateHandler }] },
],
}),
});
const run = async (runId: string): Promise<void> => {
await executeHooks({
registry,
input: {
hook_event_name: 'RunStart',
runId,
threadId: 'shared-session-start',
messages: [],
},
});
};
await run('first');
await run('second');
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
});
test('keeps SessionStart state separate across disjoint source declarations', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.sessionLifecycle = true;
const context = { sessionStartSource: 'startup' };
const duplicateHandler = { type: 'command', command: 'load-context' };
registerPluginHooks({
pluginId: 'separate-session-start',
registry,
executor: hookExecutor,
context,
document: document({
SessionStart: [
{ matcher: 'startup', hooks: [duplicateHandler] },
{ matcher: 'resume', hooks: [{ ...duplicateHandler }] },
],
}),
});
const run = async (runId: string): Promise<void> => {
await executeHooks({
registry,
input: {
hook_event_name: 'RunStart',
runId,
threadId: 'separate-session-start',
messages: [],
},
});
};
await run('startup');
context.sessionStartSource = 'resume';
await run('resume');
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
});
test('translates compaction matchers and carries the trigger into PostCompact', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
hookExecutor.capabilities.translateMatcher = ({ matcher }) =>
matcher === 'auto' ? '^(token_ratio|remaining_tokens|messages_to_refine|default)$' : matcher;
const registration = registerPluginHooks({
pluginId: 'compact-audit',
registry,
executor: hookExecutor,
document: document({
PreCompact: [
{
matcher: 'auto',
hooks: [{ type: 'command', command: 'before-compact' }],
},
],
PostCompact: [
{
matcher: 'auto',
hooks: [{ type: 'command', command: 'after-compact' }],
},
],
}),
});
await executeHooks({
registry,
input: {
hook_event_name: 'PreCompact',
runId: 'run-compact',
threadId: 'conversation-compact',
trigger: 'token_ratio',
messagesBeforeCount: 12,
},
});
await executeHooks({
registry,
input: {
hook_event_name: 'PostCompact',
runId: 'run-compact',
threadId: 'conversation-compact',
summary: 'Compacted context',
messagesAfterCount: 0,
},
});
expect(registration.registered).toBe(2);
expect(registry.getMatchers('PreCompact')).toHaveLength(2);
expect(registry.getMatchers('PostCompact')).toHaveLength(2);
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
expect(hookExecutor.execute).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
sourceEvent: 'PreCompact',
payload: expect.objectContaining({
hook_event_name: 'PreCompact',
trigger: 'auto',
messages_before_count: 12,
}),
}),
expect.any(AbortSignal),
);
expect(hookExecutor.execute).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sourceEvent: 'PostCompact',
payload: expect.objectContaining({
hook_event_name: 'PostCompact',
trigger: 'auto',
compact_summary: 'Compacted context',
messages_after_count: 0,
}),
}),
expect.any(AbortSignal),
);
await executeHooks({
registry,
input: {
hook_event_name: 'PostCompact',
runId: 'run-compact',
threadId: 'conversation-compact',
summary: 'No matching trigger',
messagesAfterCount: 0,
},
});
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
registration.unregister();
expect(registry.getMatchers('PreCompact')).toHaveLength(0);
expect(registry.getMatchers('PostCompact')).toHaveLength(0);
});
test('translates batch entries and preserves the PostToolUseFailure error field', () => {
const batchPayload = createPluginHookPayload('PostToolBatch', {
hook_event_name: 'PostToolBatch',
runId: 'run-batch',
entries: [
{
toolName: 'Read',
toolInput: { file_path: '/workspace/file.ts' },
toolUseId: 'tool-success',
toolOutput: 'file contents',
status: 'success',
},
{
toolName: 'Bash',
toolInput: { command: 'exit 1' },
toolUseId: 'tool-failure',
error: 'Command failed',
status: 'error',
},
],
});
const failurePayload = createPluginHookPayload('PostToolUseFailure', {
hook_event_name: 'PostToolUseFailure',
runId: 'run-failure',
toolName: 'Bash',
toolInput: { command: 'exit 1' },
toolUseId: 'tool-failure',
error: 'Command failed',
});
expect(batchPayload.tool_calls).toEqual([
{
tool_name: 'Read',
tool_input: { file_path: '/workspace/file.ts' },
tool_use_id: 'tool-success',
tool_response: 'file contents',
},
{
tool_name: 'Bash',
tool_input: { command: 'exit 1' },
tool_use_id: 'tool-failure',
tool_response: 'Command failed',
},
]);
expect(batchPayload).not.toHaveProperty('entries');
expect(failurePayload).toEqual(
expect.objectContaining({
hook_event_name: 'PostToolUseFailure',
error: 'Command failed',
}),
);
expect(failurePayload).not.toHaveProperty('tool_error');
});
test('emits the documented SubagentStart identity fields', () => {
const payload = createPluginHookPayload('SubagentStart', {
hook_event_name: 'SubagentStart',
runId: 'run-subagent',
parentAgentId: 'agent-parent',
agentId: 'agent-child',
agentType: 'Explore',
inputs: [],
});
expect(payload).toEqual(
expect.objectContaining({
hook_event_name: 'SubagentStart',
agent_id: 'agent-child',
agent_type: 'Explore',
}),
);
});
test('includes StopFailure assistant text when the SDK provides it', () => {
const payload = createPluginHookPayload('StopFailure', {
hook_event_name: 'StopFailure',
runId: 'run-stop-failure',
error: 'Model response could not be parsed',
lastAssistantMessage: new AIMessage('Partial assistant response'),
});
expect(payload).toEqual(
expect.objectContaining({
hook_event_name: 'StopFailure',
error: 'Model response could not be parsed',
last_assistant_message: 'Partial assistant response',
}),
);
});
test('filters StopFailure matchers against the runtime error', async () => {
const registry = new HookRegistry();
const hookExecutor = executor();
const registration = registerPluginHooks({
pluginId: 'failure-audit',
registry,
executor: hookExecutor,
document: document({
StopFailure: [
{
matcher: 'rate_limit|overloaded',
hooks: [{ type: 'command', command: 'record-failure' }],
},
],
}),
});
expect(registration.registered).toBe(1);
expect(registry.getMatchers('StopFailure')[0].pattern).toBeUndefined();
await executeHooks({
registry,
input: {
hook_event_name: 'StopFailure',
runId: 'run-auth-failure',
error: 'authentication_failed',
},
});
await executeHooks({
registry,
input: {
hook_event_name: 'StopFailure',
runId: 'run-rate-limit',
error: 'rate_limit',
},
});
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
expect(hookExecutor.execute).toHaveBeenCalledWith(
expect.objectContaining({
sourceEvent: 'StopFailure',
targetEvent: 'StopFailure',
payload: expect.objectContaining({
hook_event_name: 'StopFailure',
error: 'rate_limit',
}),
}),
expect.any(AbortSignal),
);
});
test('registers the event surface used by the official hookify plugin', () => {
const registry = new HookRegistry();
const registration = registerPluginHooks({
pluginId: 'hookify',
registry,
executor: executor(),
document: document({
PreToolUse: [{ hooks: [{ type: 'command', command: 'pretooluse.py' }] }],
PostToolUse: [{ hooks: [{ type: 'command', command: 'posttooluse.py' }] }],
Stop: [{ hooks: [{ type: 'command', command: 'stop.py' }] }],
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'userpromptsubmit.py' }] }],
}),
});
expect(registration.registered).toBe(4);
expect(registration.plan.summary).toEqual({ declared: 4, ready: 4, unsupported: 0 });
expect(registry.getMatchers('PreToolUse')).toHaveLength(1);
expect(registry.getMatchers('PostToolUse')).toHaveLength(1);
expect(registry.getMatchers('Stop')).toHaveLength(1);
expect(registry.getMatchers('UserPromptSubmit')).toHaveLength(1);
});
test('leaves unsupported declarations in the plan without registering them', () => {
const registry = new HookRegistry();
const registration = registerPluginHooks({
pluginId: 'mixed-plugin',
registry,
executor: executor(),
document: document({
UserPromptExpansion: [{ hooks: [{ type: 'command', command: 'banner' }] }],
Stop: [{ hooks: [{ type: 'prompt', prompt: 'Verify completion' }] }],
}),
});
expect(registration.registered).toBe(0);
expect(registration.plan.summary.unsupported).toBe(2);
expect(registry.getMatchers('Stop')).toHaveLength(0);
});
test('unregisters only this plugin registration and is idempotent', () => {
const registry = new HookRegistry();
const existing = jest.fn(async () => ({}));
registry.register('Stop', { hooks: [existing] });
const registration = registerPluginHooks({
pluginId: 'ralph-loop',
registry,
executor: executor(),
document: document({
Stop: [{ hooks: [{ type: 'command', command: 'stop-hook.sh' }] }],
}),
});
expect(registry.getMatchers('Stop')).toHaveLength(2);
registration.unregister();
registration.unregister();
expect(registry.getMatchers('Stop')).toHaveLength(1);
expect(registry.getMatchers('Stop')[0].hooks[0]).toBe(existing);
});
});

View file

@ -0,0 +1,473 @@
import { HookRegistry, matchesQuery } from '@librechat/agents';
import type {
HookInput,
HookOutput,
HookMatcher,
HookCallback,
HookEvent,
PostToolBatchEntry,
} from '@librechat/agents';
import type { PluginHookCapabilities, PluginHookPlan } from './compatibility';
import type { PluginHookHandler, PluginHooksDocument } from './schema';
import { planPluginHooks } from './compatibility';
export interface PluginHookRuntimeContext {
sessionId?: string;
cwd?: string;
transcriptPath?: string | null;
permissionMode?: string;
sessionStartSource?: string;
model?: string;
agentType?: string;
}
export interface PluginHookBatchToolCall {
tool_name: string;
tool_input: Record<string, unknown>;
tool_use_id: string;
tool_response?: unknown;
}
export interface PluginHookPayload {
hook_event_name: string;
session_id: string;
run_id: string;
thread_id?: string;
cwd?: string;
transcript_path?: string | null;
permission_mode?: string;
source?: string;
model?: string;
agent_id?: string;
executing_agent_id?: string;
prompt?: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
tool_use_id?: string;
tool_response?: unknown;
error?: string;
last_assistant_message?: string;
reason?: string;
agent_type?: string;
stop_hook_active?: boolean;
trigger?: string;
compact_summary?: string;
messages_before_count?: number;
messages_after_count?: number;
tool_calls?: PluginHookBatchToolCall[];
}
export interface PluginHookExecutionRequest {
pluginId: string;
sourceEvent: string;
targetEvent: HookEvent;
handler: PluginHookHandler;
condition?: string;
input: HookInput;
payload: PluginHookPayload;
}
/**
* Host-owned boundary for executing a planned hook.
* This compatibility layer never launches plugin code in the LibreChat API process.
*/
export interface PluginHookExecutor {
capabilities: PluginHookCapabilities;
execute(
request: PluginHookExecutionRequest,
signal: AbortSignal,
): HookOutput | Promise<HookOutput>;
}
export interface RegisterPluginHooksOptions {
pluginId: string;
registry: HookRegistry;
document: PluginHooksDocument;
executor: PluginHookExecutor;
context?: PluginHookRuntimeContext;
}
export interface PluginHookRegistration {
plan: PluginHookPlan;
registered: number;
unregister: () => void;
}
interface PluginHookPayloadState {
compactTrigger?: string;
toPluginToolName?: (toolName: string) => string;
}
function getSessionId(input: HookInput, context: PluginHookRuntimeContext): string {
return context.sessionId ?? input.threadId ?? input.runId;
}
function toClaudeCompactTrigger(trigger: string | undefined): string | undefined {
if (!trigger) {
return undefined;
}
return trigger === 'manual' ? 'manual' : 'auto';
}
function getPluginToolName(toolName: string, state: PluginHookPayloadState): string {
if (!state.toPluginToolName) {
return toolName;
}
const translated = state.toPluginToolName(toolName);
if (!translated?.trim()) {
throw new Error(`No plugin tool-name mapping is available for "${toolName}"`);
}
return translated;
}
function getMessageText(message: unknown): string | undefined {
if (!message || typeof message !== 'object' || !('content' in message)) {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return undefined;
}
const text = content
.flatMap((block) => {
if (typeof block === 'string') {
return [block];
}
if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') {
return [block.text];
}
return [];
})
.join('\n');
return text || undefined;
}
function toPluginBatchToolCall(
entry: PostToolBatchEntry,
state: PluginHookPayloadState,
): PluginHookBatchToolCall {
const toolResponse = entry.status === 'success' ? entry.toolOutput : entry.error;
return {
tool_name: getPluginToolName(entry.toolName, state),
tool_input: entry.toolInput,
tool_use_id: entry.toolUseId,
...(toolResponse !== undefined && { tool_response: toolResponse }),
};
}
function basePayload(
sourceEvent: string,
input: HookInput,
context: PluginHookRuntimeContext,
): PluginHookPayload {
const sessionId = getSessionId(input, context);
return {
hook_event_name: sourceEvent,
session_id: sessionId,
run_id: input.runId,
...(input.threadId !== undefined && { thread_id: input.threadId }),
...(context.cwd !== undefined && { cwd: context.cwd }),
...(context.transcriptPath !== undefined && { transcript_path: context.transcriptPath }),
...(context.permissionMode !== undefined && { permission_mode: context.permissionMode }),
...(input.agentId !== undefined && { agent_id: input.agentId }),
...(input.executingAgentId !== undefined && {
executing_agent_id: input.executingAgentId,
}),
};
}
export function createPluginHookPayload(
sourceEvent: string,
input: HookInput,
context: PluginHookRuntimeContext = {},
state: PluginHookPayloadState = {},
): PluginHookPayload {
const payload = basePayload(sourceEvent, input, context);
switch (input.hook_event_name) {
case 'RunStart':
return {
...payload,
...(sourceEvent === 'SessionStart' && {
source: context.sessionStartSource ?? 'startup',
...(context.model !== undefined && { model: context.model }),
...(context.agentType !== undefined && { agent_type: context.agentType }),
}),
};
case 'UserPromptSubmit':
return { ...payload, prompt: input.prompt };
case 'PreToolUse':
return {
...payload,
tool_name: getPluginToolName(input.toolName, state),
tool_input: input.toolInput,
tool_use_id: input.toolUseId,
};
case 'PostToolUse':
return {
...payload,
tool_name: getPluginToolName(input.toolName, state),
tool_input: input.toolInput,
tool_use_id: input.toolUseId,
tool_response: input.toolOutput,
};
case 'PostToolUseFailure':
return {
...payload,
tool_name: getPluginToolName(input.toolName, state),
tool_input: input.toolInput,
tool_use_id: input.toolUseId,
error: input.error,
};
case 'PostToolBatch':
return {
...payload,
tool_calls: input.entries.map((entry) => toPluginBatchToolCall(entry, state)),
};
/** LibreChat steering seal; no Claude counterpart, so it is never mapped to a declaration. */
case 'PreemptBoundary':
return payload;
case 'PermissionDenied':
return {
...payload,
tool_name: getPluginToolName(input.toolName, state),
tool_input: input.toolInput,
tool_use_id: input.toolUseId,
reason: input.reason,
};
case 'SubagentStart':
case 'SubagentStop':
return { ...payload, agent_type: input.agentType };
case 'Stop':
return { ...payload, stop_hook_active: input.stopHookActive };
case 'StopFailure': {
const lastAssistantMessage = getMessageText(input.lastAssistantMessage);
return {
...payload,
error: input.error,
...(lastAssistantMessage !== undefined && {
last_assistant_message: lastAssistantMessage,
}),
};
}
case 'PreCompact':
return {
...payload,
trigger: toClaudeCompactTrigger(input.trigger),
messages_before_count: input.messagesBeforeCount,
};
case 'PostCompact':
return {
...payload,
...(state.compactTrigger !== undefined && {
trigger: toClaudeCompactTrigger(state.compactTrigger),
}),
compact_summary: input.summary,
messages_after_count: input.messagesAfterCount,
};
}
}
function getHookTimeoutMs(
sourceEvent: string,
handler: PluginHookHandler,
configuredTimeoutMs: number | undefined,
): number | undefined {
if (configuredTimeoutMs !== undefined) {
return configuredTimeoutMs;
}
if (handler.type === 'command') {
return sourceEvent === 'UserPromptSubmit' ? 30_000 : 600_000;
}
if (handler.type === 'prompt') {
return 30_000;
}
return undefined;
}
function getHandlerIdentity(
sourceEvent: string,
handler: PluginHookHandler,
condition: string | undefined,
): string {
const handlerProperties = Object.entries(handler)
.filter(([key]) => key !== 'if')
.sort(([left], [right]) => left.localeCompare(right));
return JSON.stringify([sourceEvent, condition ?? null, handlerProperties]);
}
export function registerPluginHooks(options: RegisterPluginHooksOptions): PluginHookRegistration {
const { pluginId, registry, document, executor, context = {} } = options;
const plan = planPluginHooks(document, executor.capabilities);
const unregisters: Array<() => void> = [];
const compactTriggers = new Map<string, string>();
const executedHandlers = new WeakMap<HookInput, Set<string>>();
const tracksCompactTriggers = plan.entries.some(
(entry) => entry.status === 'ready' && entry.targetEvent === 'PostCompact',
);
let registered = 0;
if (tracksCompactTriggers) {
const trackCompactTrigger: HookCallback<'PreCompact'> = (input) => {
compactTriggers.set(getSessionId(input, context), input.trigger);
return {};
};
unregisters.push(
registry.register('PreCompact', {
hooks: [trackCompactTrigger],
internal: true,
}),
);
}
for (const entry of plan.entries) {
if (entry.status !== 'ready' || entry.targetEvent === undefined) {
continue;
}
const targetEvent = entry.targetEvent;
const handlerIdentity = getHandlerIdentity(entry.sourceEvent, entry.handler, entry.condition);
const seenSessionIds = entry.sourceEvent === 'SessionStart' ? new Set<string>() : undefined;
const firedSessionIds = entry.handler.once === true ? new Set<string>() : undefined;
const toolNameTranslator = executor.capabilities.toPluginToolName;
const toPluginToolName =
toolNameTranslator === undefined
? undefined
: (toolName: string): string =>
toolNameTranslator({
sourceEvent: entry.sourceEvent,
targetEvent,
toolName,
});
const hook: HookCallback<HookEvent> = (input, signal) => {
const sessionId = getSessionId(input, context);
const compactTrigger = compactTriggers.get(sessionId);
if (entry.sourceEvent === 'SessionStart') {
const source = context.sessionStartSource ?? 'startup';
if (
source === 'compact' ||
!matchesQuery(entry.matcher, source) ||
seenSessionIds?.has(sessionId) === true
) {
return {};
}
seenSessionIds?.add(sessionId);
}
if (
targetEvent === 'PreCompact' &&
input.hook_event_name === 'PreCompact' &&
!matchesQuery(entry.matcher, input.trigger)
) {
return {};
}
if (
targetEvent === 'PostCompact' &&
input.hook_event_name === 'PostCompact' &&
(compactTrigger === undefined || !matchesQuery(entry.matcher, compactTrigger))
) {
return {};
}
if (
targetEvent === 'StopFailure' &&
input.hook_event_name === 'StopFailure' &&
!matchesQuery(entry.matcher, input.error)
) {
return {};
}
if (entry.condition !== undefined) {
const matchCondition = executor.capabilities.matchCondition;
if (!matchCondition || !('toolName' in input) || !('toolInput' in input)) {
return {};
}
let matchesCondition = false;
try {
matchesCondition = matchCondition({
sourceEvent: entry.sourceEvent,
targetEvent,
condition: entry.condition,
toolName: getPluginToolName(input.toolName, { toPluginToolName }),
toolInput: input.toolInput,
});
} catch {
return {};
}
if (!matchesCondition) {
return {};
}
}
if (firedSessionIds?.has(sessionId) === true) {
return {};
}
const handlersForInput = executedHandlers.get(input);
if (handlersForInput?.has(handlerIdentity) === true) {
firedSessionIds?.add(sessionId);
return {};
}
if (handlersForInput) {
handlersForInput.add(handlerIdentity);
} else {
executedHandlers.set(input, new Set([handlerIdentity]));
}
firedSessionIds?.add(sessionId);
return executor.execute(
{
pluginId,
sourceEvent: entry.sourceEvent,
targetEvent,
handler: entry.handler,
...(entry.condition !== undefined && { condition: entry.condition }),
input,
payload: createPluginHookPayload(entry.sourceEvent, input, context, {
compactTrigger,
toPluginToolName,
}),
},
signal,
);
};
const runtimeFiltered =
entry.sourceEvent === 'SessionStart' ||
targetEvent === 'PreCompact' ||
targetEvent === 'PostCompact' ||
targetEvent === 'StopFailure';
const timeoutMs = getHookTimeoutMs(entry.sourceEvent, entry.handler, entry.timeoutMs);
const matcher: HookMatcher<HookEvent> = {
hooks: [hook],
...(!runtimeFiltered && entry.matcher !== undefined && { pattern: entry.matcher }),
...(timeoutMs !== undefined && { timeout: timeoutMs }),
};
unregisters.push(registry.register(targetEvent, matcher));
registered++;
}
if (tracksCompactTriggers) {
const clearCompactTrigger: HookCallback<'PostCompact'> = (input) => {
compactTriggers.delete(getSessionId(input, context));
return {};
};
unregisters.push(
registry.register('PostCompact', {
hooks: [clearCompactTrigger],
internal: true,
}),
);
}
let active = true;
return {
plan,
registered,
unregister: () => {
if (!active) {
return;
}
active = false;
compactTriggers.clear();
for (let index = unregisters.length - 1; index >= 0; index--) {
unregisters[index]();
}
},
};
}

View file

@ -0,0 +1,301 @@
import { MAX_PATTERN_LENGTH } from '@librechat/agents';
import { parsePluginHooks } from './schema';
describe('parsePluginHooks', () => {
test('parses the plugin hooks wrapper used by Claude and Codex', () => {
const result = parsePluginHooks({
description: 'Guard file writes',
hooks: {
PreToolUse: [
{
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: 'python3 "${CLAUDE_PLUGIN_ROOT}/hooks/check.py"',
timeout: 10,
},
],
},
],
},
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.document.hooks.PreToolUse[0].hooks[0]).toEqual({
type: 'command',
command: 'python3 "${CLAUDE_PLUGIN_ROOT}/hooks/check.py"',
timeout: 10,
});
});
test('rejects a direct settings-style event map without the plugin wrapper', () => {
const result = parsePluginHooks({
PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'check' }] }],
});
expect(result.success).toBe(false);
if (result.success) {
return;
}
expect(result.issues).toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'hooks' })]),
);
});
test('rejects command handlers without a command', () => {
const result = parsePluginHooks({
hooks: {
Stop: [{ hooks: [{ type: 'command' }] }],
},
});
expect(result.success).toBe(false);
if (result.success) {
return;
}
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'hooks.Stop.0.hooks.0.command',
message: 'Command hooks require a non-empty command',
}),
]),
);
});
test('rejects prompt handlers without a prompt', () => {
const result = parsePluginHooks({
hooks: {
Stop: [{ hooks: [{ type: 'prompt' }] }],
},
});
expect(result.success).toBe(false);
if (result.success) {
return;
}
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'hooks.Stop.0.hooks.0.prompt',
message: 'Prompt hooks require a non-empty prompt',
}),
]),
);
});
test('retains compatibility modifiers needed by the planner', () => {
const result = parsePluginHooks({
hooks: {
PostToolUse: [
{
matcher: ' Bash ',
hooks: [
{
type: 'command',
command: 'review',
if: ' Bash(git commit:*) ',
asyncRewake: true,
rewakeMessage: 'Review these findings',
rewakeSummary: 'Review complete',
continueOnBlock: true,
},
],
},
],
},
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.document.hooks.PostToolUse[0]).toEqual({
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'review',
if: 'Bash(git commit:*)',
asyncRewake: true,
rewakeMessage: 'Review these findings',
rewakeSummary: 'Review complete',
continueOnBlock: true,
},
],
});
});
test('retains official command and prompt handler options', () => {
const result = parsePluginHooks({
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 'verify',
args: ['--mode', 'strict'],
shell: 'bash',
once: true,
},
{
type: 'prompt',
prompt: 'Check whether the task is complete',
model: 'claude-sonnet-4-5',
},
],
},
],
},
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.document.hooks.Stop[0].hooks).toEqual([
{
type: 'command',
command: 'verify',
args: ['--mode', 'strict'],
shell: 'bash',
once: true,
},
{
type: 'prompt',
prompt: 'Check whether the task is complete',
model: 'claude-sonnet-4-5',
},
]);
});
test('retains schema metadata and known handlers for compatibility planning', () => {
const result = parsePluginHooks({
$schema: 'https://json.schemastore.org/claude-code-settings.json',
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'verify',
},
{
type: 'http',
url: 'https://hooks.example.com/pre-tool',
headers: { Authorization: 'Bearer ${HOOK_TOKEN}' },
allowedEnvVars: ['HOOK_TOKEN'],
},
{
type: 'mcp_tool',
server: 'policy',
tool: 'validate',
input: { strict: true },
},
{
type: 'agent',
prompt: 'Review this tool call',
model: 'claude-sonnet-4-5',
},
],
},
],
},
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.document.$schema).toBe('https://json.schemastore.org/claude-code-settings.json');
expect(result.document.hooks.PreToolUse[0].hooks).toEqual([
{
type: 'command',
command: 'verify',
},
{
type: 'http',
url: 'https://hooks.example.com/pre-tool',
headers: { Authorization: 'Bearer ${HOOK_TOKEN}' },
allowedEnvVars: ['HOOK_TOKEN'],
},
{
type: 'mcp_tool',
server: 'policy',
tool: 'validate',
input: { strict: true },
},
{
type: 'agent',
prompt: 'Review this tool call',
model: 'claude-sonnet-4-5',
},
]);
});
test('rejects blank conditions and oversized matchers', () => {
const blankCondition = parsePluginHooks({
hooks: {
PostToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'review', if: ' ' }],
},
],
},
});
const oversizedMatcher = parsePluginHooks({
hooks: {
PostToolUse: [
{
matcher: 'a'.repeat(MAX_PATTERN_LENGTH + 1),
hooks: [{ type: 'command', command: 'review' }],
},
],
},
});
expect(blankCondition.success).toBe(false);
expect(oversizedMatcher.success).toBe(false);
if (blankCondition.success || oversizedMatcher.success) {
return;
}
expect(blankCondition.issues).toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'hooks.PostToolUse.0.hooks.0.if' })]),
);
expect(oversizedMatcher.issues).toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'hooks.PostToolUse.0.matcher' })]),
);
});
test('rejects unrecognized behavior instead of silently stripping it', () => {
const result = parsePluginHooks({
hooks: {
Stop: [
{
hooks: [{ type: 'command', command: 'verify', futureMode: 'detached' }],
},
],
},
});
expect(result.success).toBe(false);
if (result.success) {
return;
}
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'hooks.Stop.0.hooks.0',
message: expect.stringContaining('futureMode'),
}),
]),
);
});
});

View file

@ -0,0 +1,199 @@
import { z } from 'zod';
import { MAX_PATTERN_LENGTH } from '@librechat/agents';
const MAX_DESCRIPTION_LENGTH = 2_000;
const MAX_COMMAND_LENGTH = 32_768;
const MAX_HANDLER_TYPE_LENGTH = 64;
const MAX_STATUS_MESSAGE_LENGTH = 500;
const MAX_EVENT_NAME_LENGTH = 80;
const MAX_GROUPS_PER_EVENT = 128;
const MAX_HANDLERS_PER_GROUP = 32;
const MAX_TOTAL_HANDLERS = 512;
const MAX_TIMEOUT_SECONDS = 3_600;
const MAX_COMMAND_ARGS = 256;
const MAX_HTTP_HEADERS = 256;
export interface PluginHookHandler {
type: string;
command?: string;
commandWindows?: string;
args?: string[];
shell?: 'bash' | 'powershell';
url?: string;
headers?: Record<string, string>;
allowedEnvVars?: string[];
server?: string;
tool?: string;
input?: Record<string, unknown>;
prompt?: string;
model?: string;
timeout?: number;
statusMessage?: string;
if?: string;
once?: boolean;
continueOnBlock?: boolean;
async?: boolean;
asyncRewake?: boolean;
rewakeMessage?: string;
rewakeSummary?: string;
}
export interface PluginHookGroup {
matcher?: string;
if?: string;
hooks: PluginHookHandler[];
}
export interface PluginHooksDocument {
$schema?: string;
description?: string;
hooks: Record<string, PluginHookGroup[]>;
}
export const pluginHookHandlerSchema: z.ZodType<PluginHookHandler> = z
.object({
type: z.string().trim().min(1).max(MAX_HANDLER_TYPE_LENGTH),
command: z.string().min(1).max(MAX_COMMAND_LENGTH).optional(),
commandWindows: z.string().min(1).max(MAX_COMMAND_LENGTH).optional(),
args: z.array(z.string().max(MAX_COMMAND_LENGTH)).max(MAX_COMMAND_ARGS).optional(),
shell: z.enum(['bash', 'powershell']).optional(),
url: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
headers: z
.record(
z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH),
z.string().max(MAX_COMMAND_LENGTH),
)
.refine((headers) => Object.keys(headers).length <= MAX_HTTP_HEADERS, {
message: `HTTP hooks may declare at most ${MAX_HTTP_HEADERS} headers`,
})
.optional(),
allowedEnvVars: z
.array(z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH))
.max(MAX_COMMAND_ARGS)
.optional(),
server: z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH).optional(),
tool: z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH).optional(),
input: z.record(z.string(), z.unknown()).optional(),
prompt: z.string().min(1).max(MAX_COMMAND_LENGTH).optional(),
model: z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH).optional(),
timeout: z.number().int().positive().max(MAX_TIMEOUT_SECONDS).optional(),
statusMessage: z.string().max(MAX_STATUS_MESSAGE_LENGTH).optional(),
if: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
once: z.boolean().optional(),
continueOnBlock: z.boolean().optional(),
async: z.boolean().optional(),
asyncRewake: z.boolean().optional(),
rewakeMessage: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
rewakeSummary: z.string().max(MAX_STATUS_MESSAGE_LENGTH).optional(),
})
.strict()
.superRefine((handler, context) => {
if (handler.type === 'command' && !handler.command?.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['command'],
message: 'Command hooks require a non-empty command',
});
}
if (handler.type === 'prompt' && !handler.prompt?.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['prompt'],
message: 'Prompt hooks require a non-empty prompt',
});
}
if (handler.type === 'agent' && !handler.prompt?.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['prompt'],
message: 'Agent hooks require a non-empty prompt',
});
}
if (handler.type === 'http' && !handler.url) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['url'],
message: 'HTTP hooks require a non-empty URL',
});
}
if (handler.type === 'mcp_tool' && !handler.server) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['server'],
message: 'MCP tool hooks require a non-empty server',
});
}
if (handler.type === 'mcp_tool' && !handler.tool) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tool'],
message: 'MCP tool hooks require a non-empty tool',
});
}
});
export const pluginHookGroupSchema: z.ZodType<PluginHookGroup> = z
.object({
matcher: z.string().trim().max(MAX_PATTERN_LENGTH).optional(),
/** Retained for older hook bundles; current Claude plugins declare `if` per handler. */
if: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
hooks: z.array(pluginHookHandlerSchema).min(1).max(MAX_HANDLERS_PER_GROUP),
})
.strict();
const pluginHookEventsSchema = z.record(
z.string().trim().min(1).max(MAX_EVENT_NAME_LENGTH),
z.array(pluginHookGroupSchema).min(1).max(MAX_GROUPS_PER_EVENT),
);
export const pluginHooksDocumentSchema: z.ZodType<PluginHooksDocument> = z
.object({
$schema: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
hooks: pluginHookEventsSchema,
})
.strict()
.superRefine((document, context) => {
let total = 0;
for (const groups of Object.values(document.hooks)) {
for (const group of groups) {
total += group.hooks.length;
if (total <= MAX_TOTAL_HANDLERS) {
continue;
}
context.addIssue({
code: z.ZodIssueCode.too_big,
type: 'array',
maximum: MAX_TOTAL_HANDLERS,
inclusive: true,
path: ['hooks'],
message: `Hook documents may declare at most ${MAX_TOTAL_HANDLERS} handlers`,
});
return;
}
}
});
export interface PluginHookValidationIssue {
path: string;
message: string;
}
export type PluginHooksParseResult =
| { success: true; document: PluginHooksDocument }
| { success: false; issues: PluginHookValidationIssue[] };
/** Parse Claude's plugin-specific `{"hooks": {...}}` wrapper. */
export function parsePluginHooks(input: unknown): PluginHooksParseResult {
const parsed = pluginHooksDocumentSchema.safeParse(input);
if (parsed.success) {
return { success: true, document: parsed.data };
}
return {
success: false,
issues: parsed.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
};
}

View file

@ -38,6 +38,7 @@ export * from './validation';
export * from './added';
export * from './load';
export * from './hitl';
export * from './hooks';
export * from './steering';
export * from './activityLabels';
export * from './toolValidation';