2026-01-18 05:40:58 +00:00
|
|
|
/**
|
|
|
|
|
* Plugin Hook Runner
|
|
|
|
|
*
|
|
|
|
|
* Provides utilities for executing plugin lifecycle hooks with proper
|
|
|
|
|
* error handling, priority ordering, and async support.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { PluginRegistry } from "./registry.js";
|
|
|
|
|
import type {
|
|
|
|
|
PluginHookAfterCompactionEvent,
|
|
|
|
|
PluginHookAfterToolCallEvent,
|
|
|
|
|
PluginHookAgentContext,
|
|
|
|
|
PluginHookAgentEndEvent,
|
|
|
|
|
PluginHookBeforeAgentStartEvent,
|
|
|
|
|
PluginHookBeforeAgentStartResult,
|
2026-02-17 03:28:10 +01:00
|
|
|
PluginHookBeforeModelResolveEvent,
|
|
|
|
|
PluginHookBeforeModelResolveResult,
|
|
|
|
|
PluginHookBeforePromptBuildEvent,
|
|
|
|
|
PluginHookBeforePromptBuildResult,
|
2026-01-18 05:40:58 +00:00
|
|
|
PluginHookBeforeCompactionEvent,
|
2026-02-15 14:01:00 -08:00
|
|
|
PluginHookLlmInputEvent,
|
|
|
|
|
PluginHookLlmOutputEvent,
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
PluginHookBeforeResetEvent,
|
2026-01-18 05:40:58 +00:00
|
|
|
PluginHookBeforeToolCallEvent,
|
|
|
|
|
PluginHookBeforeToolCallResult,
|
|
|
|
|
PluginHookGatewayContext,
|
|
|
|
|
PluginHookGatewayStartEvent,
|
|
|
|
|
PluginHookGatewayStopEvent,
|
|
|
|
|
PluginHookMessageContext,
|
|
|
|
|
PluginHookMessageReceivedEvent,
|
|
|
|
|
PluginHookMessageSendingEvent,
|
|
|
|
|
PluginHookMessageSendingResult,
|
|
|
|
|
PluginHookMessageSentEvent,
|
|
|
|
|
PluginHookName,
|
|
|
|
|
PluginHookRegistration,
|
|
|
|
|
PluginHookSessionContext,
|
|
|
|
|
PluginHookSessionEndEvent,
|
|
|
|
|
PluginHookSessionStartEvent,
|
2026-02-21 16:14:55 +01:00
|
|
|
PluginHookSubagentContext,
|
|
|
|
|
PluginHookSubagentDeliveryTargetEvent,
|
|
|
|
|
PluginHookSubagentDeliveryTargetResult,
|
|
|
|
|
PluginHookSubagentSpawningEvent,
|
|
|
|
|
PluginHookSubagentSpawningResult,
|
|
|
|
|
PluginHookSubagentEndedEvent,
|
|
|
|
|
PluginHookSubagentSpawnedEvent,
|
2026-01-18 05:40:58 +00:00
|
|
|
PluginHookToolContext,
|
2026-01-19 13:11:31 +01:00
|
|
|
PluginHookToolResultPersistContext,
|
|
|
|
|
PluginHookToolResultPersistEvent,
|
|
|
|
|
PluginHookToolResultPersistResult,
|
2026-02-16 00:36:48 -08:00
|
|
|
PluginHookBeforeMessageWriteEvent,
|
|
|
|
|
PluginHookBeforeMessageWriteResult,
|
2026-01-18 05:40:58 +00:00
|
|
|
} from "./types.js";
|
|
|
|
|
|
|
|
|
|
// Re-export types for consumers
|
|
|
|
|
export type {
|
|
|
|
|
PluginHookAgentContext,
|
|
|
|
|
PluginHookBeforeAgentStartEvent,
|
|
|
|
|
PluginHookBeforeAgentStartResult,
|
2026-02-17 03:28:10 +01:00
|
|
|
PluginHookBeforeModelResolveEvent,
|
|
|
|
|
PluginHookBeforeModelResolveResult,
|
|
|
|
|
PluginHookBeforePromptBuildEvent,
|
|
|
|
|
PluginHookBeforePromptBuildResult,
|
2026-02-15 14:01:00 -08:00
|
|
|
PluginHookLlmInputEvent,
|
|
|
|
|
PluginHookLlmOutputEvent,
|
2026-01-18 05:40:58 +00:00
|
|
|
PluginHookAgentEndEvent,
|
|
|
|
|
PluginHookBeforeCompactionEvent,
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
PluginHookBeforeResetEvent,
|
2026-01-18 05:40:58 +00:00
|
|
|
PluginHookAfterCompactionEvent,
|
|
|
|
|
PluginHookMessageContext,
|
|
|
|
|
PluginHookMessageReceivedEvent,
|
|
|
|
|
PluginHookMessageSendingEvent,
|
|
|
|
|
PluginHookMessageSendingResult,
|
|
|
|
|
PluginHookMessageSentEvent,
|
|
|
|
|
PluginHookToolContext,
|
|
|
|
|
PluginHookBeforeToolCallEvent,
|
|
|
|
|
PluginHookBeforeToolCallResult,
|
|
|
|
|
PluginHookAfterToolCallEvent,
|
2026-01-19 13:11:31 +01:00
|
|
|
PluginHookToolResultPersistContext,
|
|
|
|
|
PluginHookToolResultPersistEvent,
|
|
|
|
|
PluginHookToolResultPersistResult,
|
2026-02-16 00:36:48 -08:00
|
|
|
PluginHookBeforeMessageWriteEvent,
|
|
|
|
|
PluginHookBeforeMessageWriteResult,
|
2026-01-18 05:40:58 +00:00
|
|
|
PluginHookSessionContext,
|
|
|
|
|
PluginHookSessionStartEvent,
|
|
|
|
|
PluginHookSessionEndEvent,
|
2026-02-21 16:14:55 +01:00
|
|
|
PluginHookSubagentContext,
|
|
|
|
|
PluginHookSubagentDeliveryTargetEvent,
|
|
|
|
|
PluginHookSubagentDeliveryTargetResult,
|
|
|
|
|
PluginHookSubagentSpawningEvent,
|
|
|
|
|
PluginHookSubagentSpawningResult,
|
|
|
|
|
PluginHookSubagentSpawnedEvent,
|
|
|
|
|
PluginHookSubagentEndedEvent,
|
2026-01-18 05:40:58 +00:00
|
|
|
PluginHookGatewayContext,
|
|
|
|
|
PluginHookGatewayStartEvent,
|
|
|
|
|
PluginHookGatewayStopEvent,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type HookRunnerLogger = {
|
|
|
|
|
debug?: (message: string) => void;
|
|
|
|
|
warn: (message: string) => void;
|
|
|
|
|
error: (message: string) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type HookRunnerOptions = {
|
|
|
|
|
logger?: HookRunnerLogger;
|
|
|
|
|
/** If true, errors in hooks will be caught and logged instead of thrown */
|
|
|
|
|
catchErrors?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get hooks for a specific hook name, sorted by priority (higher first).
|
|
|
|
|
*/
|
|
|
|
|
function getHooksForName<K extends PluginHookName>(
|
|
|
|
|
registry: PluginRegistry,
|
|
|
|
|
hookName: K,
|
|
|
|
|
): PluginHookRegistration<K>[] {
|
|
|
|
|
return (registry.typedHooks as PluginHookRegistration<K>[])
|
|
|
|
|
.filter((h) => h.hookName === hookName)
|
2026-01-31 16:03:28 +09:00
|
|
|
.toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
2026-01-18 05:40:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a hook runner for a specific registry.
|
|
|
|
|
*/
|
|
|
|
|
export function createHookRunner(registry: PluginRegistry, options: HookRunnerOptions = {}) {
|
|
|
|
|
const logger = options.logger;
|
|
|
|
|
const catchErrors = options.catchErrors ?? true;
|
|
|
|
|
|
2026-02-17 03:28:10 +01:00
|
|
|
const mergeBeforeModelResolve = (
|
|
|
|
|
acc: PluginHookBeforeModelResolveResult | undefined,
|
|
|
|
|
next: PluginHookBeforeModelResolveResult,
|
|
|
|
|
): PluginHookBeforeModelResolveResult => ({
|
|
|
|
|
// Keep the first defined override so higher-priority hooks win.
|
|
|
|
|
modelOverride: acc?.modelOverride ?? next.modelOverride,
|
|
|
|
|
providerOverride: acc?.providerOverride ?? next.providerOverride,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const mergeBeforePromptBuild = (
|
|
|
|
|
acc: PluginHookBeforePromptBuildResult | undefined,
|
|
|
|
|
next: PluginHookBeforePromptBuildResult,
|
|
|
|
|
): PluginHookBeforePromptBuildResult => ({
|
|
|
|
|
systemPrompt: next.systemPrompt ?? acc?.systemPrompt,
|
|
|
|
|
prependContext:
|
|
|
|
|
acc?.prependContext && next.prependContext
|
|
|
|
|
? `${acc.prependContext}\n\n${next.prependContext}`
|
|
|
|
|
: (next.prependContext ?? acc?.prependContext),
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 16:14:55 +01:00
|
|
|
const mergeSubagentSpawningResult = (
|
|
|
|
|
acc: PluginHookSubagentSpawningResult | undefined,
|
|
|
|
|
next: PluginHookSubagentSpawningResult,
|
|
|
|
|
): PluginHookSubagentSpawningResult => {
|
|
|
|
|
if (acc?.status === "error") {
|
|
|
|
|
return acc;
|
|
|
|
|
}
|
|
|
|
|
if (next.status === "error") {
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
status: "ok",
|
|
|
|
|
threadBindingReady: Boolean(acc?.threadBindingReady || next.threadBindingReady),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mergeSubagentDeliveryTargetResult = (
|
|
|
|
|
acc: PluginHookSubagentDeliveryTargetResult | undefined,
|
|
|
|
|
next: PluginHookSubagentDeliveryTargetResult,
|
|
|
|
|
): PluginHookSubagentDeliveryTargetResult => {
|
|
|
|
|
if (acc?.origin) {
|
|
|
|
|
return acc;
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-22 21:18:53 +00:00
|
|
|
const handleHookError = (params: {
|
|
|
|
|
hookName: PluginHookName;
|
|
|
|
|
pluginId: string;
|
|
|
|
|
error: unknown;
|
|
|
|
|
}): never | void => {
|
|
|
|
|
const msg = `[hooks] ${params.hookName} handler from ${params.pluginId} failed: ${String(
|
|
|
|
|
params.error,
|
|
|
|
|
)}`;
|
|
|
|
|
if (catchErrors) {
|
|
|
|
|
logger?.error(msg);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
throw new Error(msg, { cause: params.error });
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-18 05:40:58 +00:00
|
|
|
/**
|
|
|
|
|
* Run a hook that doesn't return a value (fire-and-forget style).
|
|
|
|
|
* All handlers are executed in parallel for performance.
|
|
|
|
|
*/
|
|
|
|
|
async function runVoidHook<K extends PluginHookName>(
|
|
|
|
|
hookName: K,
|
|
|
|
|
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
|
|
|
|
|
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const hooks = getHooksForName(registry, hookName);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hooks.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-18 05:40:58 +00:00
|
|
|
|
|
|
|
|
logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers)`);
|
|
|
|
|
|
|
|
|
|
const promises = hooks.map(async (hook) => {
|
|
|
|
|
try {
|
|
|
|
|
await (hook.handler as (event: unknown, ctx: unknown) => Promise<void>)(event, ctx);
|
|
|
|
|
} catch (err) {
|
2026-02-22 21:18:53 +00:00
|
|
|
handleHookError({ hookName, pluginId: hook.pluginId, error: err });
|
2026-01-18 05:40:58 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await Promise.all(promises);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run a hook that can return a modifying result.
|
|
|
|
|
* Handlers are executed sequentially in priority order, and results are merged.
|
|
|
|
|
*/
|
|
|
|
|
async function runModifyingHook<K extends PluginHookName, TResult>(
|
|
|
|
|
hookName: K,
|
|
|
|
|
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
|
|
|
|
|
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
|
|
|
|
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
|
|
|
|
|
): Promise<TResult | undefined> {
|
|
|
|
|
const hooks = getHooksForName(registry, hookName);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hooks.length === 0) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-18 05:40:58 +00:00
|
|
|
|
|
|
|
|
logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, sequential)`);
|
|
|
|
|
|
|
|
|
|
let result: TResult | undefined;
|
|
|
|
|
|
|
|
|
|
for (const hook of hooks) {
|
|
|
|
|
try {
|
|
|
|
|
const handlerResult = await (
|
|
|
|
|
hook.handler as (event: unknown, ctx: unknown) => Promise<TResult>
|
|
|
|
|
)(event, ctx);
|
|
|
|
|
|
|
|
|
|
if (handlerResult !== undefined && handlerResult !== null) {
|
|
|
|
|
if (mergeResults && result !== undefined) {
|
|
|
|
|
result = mergeResults(result, handlerResult);
|
|
|
|
|
} else {
|
|
|
|
|
result = handlerResult;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-02-22 21:18:53 +00:00
|
|
|
handleHookError({ hookName, pluginId: hook.pluginId, error: err });
|
2026-01-18 05:40:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Agent Hooks
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
2026-02-17 03:28:10 +01:00
|
|
|
/**
|
|
|
|
|
* Run before_model_resolve hook.
|
|
|
|
|
* Allows plugins to override provider/model before model resolution.
|
|
|
|
|
*/
|
|
|
|
|
async function runBeforeModelResolve(
|
|
|
|
|
event: PluginHookBeforeModelResolveEvent,
|
|
|
|
|
ctx: PluginHookAgentContext,
|
|
|
|
|
): Promise<PluginHookBeforeModelResolveResult | undefined> {
|
|
|
|
|
return runModifyingHook<"before_model_resolve", PluginHookBeforeModelResolveResult>(
|
|
|
|
|
"before_model_resolve",
|
|
|
|
|
event,
|
|
|
|
|
ctx,
|
|
|
|
|
mergeBeforeModelResolve,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run before_prompt_build hook.
|
|
|
|
|
* Allows plugins to inject context and system prompt before prompt submission.
|
|
|
|
|
*/
|
|
|
|
|
async function runBeforePromptBuild(
|
|
|
|
|
event: PluginHookBeforePromptBuildEvent,
|
|
|
|
|
ctx: PluginHookAgentContext,
|
|
|
|
|
): Promise<PluginHookBeforePromptBuildResult | undefined> {
|
|
|
|
|
return runModifyingHook<"before_prompt_build", PluginHookBeforePromptBuildResult>(
|
|
|
|
|
"before_prompt_build",
|
|
|
|
|
event,
|
|
|
|
|
ctx,
|
|
|
|
|
mergeBeforePromptBuild,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:40:58 +00:00
|
|
|
/**
|
|
|
|
|
* Run before_agent_start hook.
|
2026-02-17 03:28:10 +01:00
|
|
|
* Legacy compatibility hook that combines model resolve + prompt build phases.
|
2026-01-18 05:40:58 +00:00
|
|
|
*/
|
|
|
|
|
async function runBeforeAgentStart(
|
|
|
|
|
event: PluginHookBeforeAgentStartEvent,
|
|
|
|
|
ctx: PluginHookAgentContext,
|
|
|
|
|
): Promise<PluginHookBeforeAgentStartResult | undefined> {
|
|
|
|
|
return runModifyingHook<"before_agent_start", PluginHookBeforeAgentStartResult>(
|
|
|
|
|
"before_agent_start",
|
|
|
|
|
event,
|
|
|
|
|
ctx,
|
|
|
|
|
(acc, next) => ({
|
2026-02-17 03:28:10 +01:00
|
|
|
...mergeBeforePromptBuild(acc, next),
|
|
|
|
|
...mergeBeforeModelResolve(acc, next),
|
2026-01-18 05:40:58 +00:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run agent_end hook.
|
|
|
|
|
* Allows plugins to analyze completed conversations.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runAgentEnd(
|
|
|
|
|
event: PluginHookAgentEndEvent,
|
|
|
|
|
ctx: PluginHookAgentContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("agent_end", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:01:00 -08:00
|
|
|
/**
|
|
|
|
|
* Run llm_input hook.
|
|
|
|
|
* Allows plugins to observe the exact input payload sent to the LLM.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runLlmInput(event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) {
|
|
|
|
|
return runVoidHook("llm_input", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run llm_output hook.
|
|
|
|
|
* Allows plugins to observe the exact output payload returned by the LLM.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext) {
|
|
|
|
|
return runVoidHook("llm_output", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:40:58 +00:00
|
|
|
/**
|
|
|
|
|
* Run before_compaction hook.
|
|
|
|
|
*/
|
|
|
|
|
async function runBeforeCompaction(
|
|
|
|
|
event: PluginHookBeforeCompactionEvent,
|
|
|
|
|
ctx: PluginHookAgentContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("before_compaction", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run after_compaction hook.
|
|
|
|
|
*/
|
|
|
|
|
async function runAfterCompaction(
|
|
|
|
|
event: PluginHookAfterCompactionEvent,
|
|
|
|
|
ctx: PluginHookAgentContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("after_compaction", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
/**
|
|
|
|
|
* Run before_reset hook.
|
|
|
|
|
* Fired when /new or /reset clears a session, before messages are lost.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runBeforeReset(
|
|
|
|
|
event: PluginHookBeforeResetEvent,
|
|
|
|
|
ctx: PluginHookAgentContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("before_reset", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:40:58 +00:00
|
|
|
// =========================================================================
|
|
|
|
|
// Message Hooks
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run message_received hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runMessageReceived(
|
|
|
|
|
event: PluginHookMessageReceivedEvent,
|
|
|
|
|
ctx: PluginHookMessageContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("message_received", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run message_sending hook.
|
|
|
|
|
* Allows plugins to modify or cancel outgoing messages.
|
|
|
|
|
* Runs sequentially.
|
|
|
|
|
*/
|
|
|
|
|
async function runMessageSending(
|
|
|
|
|
event: PluginHookMessageSendingEvent,
|
|
|
|
|
ctx: PluginHookMessageContext,
|
|
|
|
|
): Promise<PluginHookMessageSendingResult | undefined> {
|
|
|
|
|
return runModifyingHook<"message_sending", PluginHookMessageSendingResult>(
|
|
|
|
|
"message_sending",
|
|
|
|
|
event,
|
|
|
|
|
ctx,
|
|
|
|
|
(acc, next) => ({
|
|
|
|
|
content: next.content ?? acc?.content,
|
|
|
|
|
cancel: next.cancel ?? acc?.cancel,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run message_sent hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runMessageSent(
|
|
|
|
|
event: PluginHookMessageSentEvent,
|
|
|
|
|
ctx: PluginHookMessageContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("message_sent", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Tool Hooks
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run before_tool_call hook.
|
|
|
|
|
* Allows plugins to modify or block tool calls.
|
|
|
|
|
* Runs sequentially.
|
|
|
|
|
*/
|
|
|
|
|
async function runBeforeToolCall(
|
|
|
|
|
event: PluginHookBeforeToolCallEvent,
|
|
|
|
|
ctx: PluginHookToolContext,
|
|
|
|
|
): Promise<PluginHookBeforeToolCallResult | undefined> {
|
|
|
|
|
return runModifyingHook<"before_tool_call", PluginHookBeforeToolCallResult>(
|
|
|
|
|
"before_tool_call",
|
|
|
|
|
event,
|
|
|
|
|
ctx,
|
|
|
|
|
(acc, next) => ({
|
|
|
|
|
params: next.params ?? acc?.params,
|
|
|
|
|
block: next.block ?? acc?.block,
|
|
|
|
|
blockReason: next.blockReason ?? acc?.blockReason,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run after_tool_call hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runAfterToolCall(
|
|
|
|
|
event: PluginHookAfterToolCallEvent,
|
|
|
|
|
ctx: PluginHookToolContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("after_tool_call", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 13:11:31 +01:00
|
|
|
/**
|
|
|
|
|
* Run tool_result_persist hook.
|
|
|
|
|
*
|
|
|
|
|
* This hook is intentionally synchronous: it runs in hot paths where session
|
|
|
|
|
* transcripts are appended synchronously.
|
|
|
|
|
*
|
|
|
|
|
* Handlers are executed sequentially in priority order (higher first). Each
|
|
|
|
|
* handler may return `{ message }` to replace the message passed to the next
|
|
|
|
|
* handler.
|
|
|
|
|
*/
|
|
|
|
|
function runToolResultPersist(
|
|
|
|
|
event: PluginHookToolResultPersistEvent,
|
|
|
|
|
ctx: PluginHookToolResultPersistContext,
|
|
|
|
|
): PluginHookToolResultPersistResult | undefined {
|
|
|
|
|
const hooks = getHooksForName(registry, "tool_result_persist");
|
2026-01-31 16:19:20 +09:00
|
|
|
if (hooks.length === 0) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-19 13:11:31 +01:00
|
|
|
|
|
|
|
|
let current = event.message;
|
|
|
|
|
|
|
|
|
|
for (const hook of hooks) {
|
|
|
|
|
try {
|
2026-02-02 15:45:05 +09:00
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
2026-01-19 13:11:31 +01:00
|
|
|
const out = (hook.handler as any)({ ...event, message: current }, ctx) as
|
|
|
|
|
| PluginHookToolResultPersistResult
|
|
|
|
|
| void
|
|
|
|
|
| Promise<unknown>;
|
|
|
|
|
|
|
|
|
|
// Guard against accidental async handlers (this hook is sync-only).
|
2026-02-02 15:45:05 +09:00
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
2026-01-19 13:11:31 +01:00
|
|
|
if (out && typeof (out as any).then === "function") {
|
|
|
|
|
const msg =
|
|
|
|
|
`[hooks] tool_result_persist handler from ${hook.pluginId} returned a Promise; ` +
|
|
|
|
|
`this hook is synchronous and the result was ignored.`;
|
|
|
|
|
if (catchErrors) {
|
|
|
|
|
logger?.warn?.(msg);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new Error(msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const next = (out as PluginHookToolResultPersistResult | undefined)?.message;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (next) {
|
|
|
|
|
current = next;
|
|
|
|
|
}
|
2026-01-19 13:11:31 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
const msg = `[hooks] tool_result_persist handler from ${hook.pluginId} failed: ${String(err)}`;
|
|
|
|
|
if (catchErrors) {
|
|
|
|
|
logger?.error(msg);
|
|
|
|
|
} else {
|
2026-01-31 16:03:28 +09:00
|
|
|
throw new Error(msg, { cause: err });
|
2026-01-19 13:11:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { message: current };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 00:36:48 -08:00
|
|
|
// =========================================================================
|
|
|
|
|
// Message Write Hooks
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run before_message_write hook.
|
|
|
|
|
*
|
|
|
|
|
* This hook is intentionally synchronous: it runs on the hot path where
|
|
|
|
|
* session transcripts are appended synchronously.
|
|
|
|
|
*
|
|
|
|
|
* Handlers are executed sequentially in priority order (higher first).
|
|
|
|
|
* If any handler returns { block: true }, the message is NOT written
|
|
|
|
|
* to the session JSONL and we return immediately.
|
|
|
|
|
* If a handler returns { message }, the modified message replaces the
|
|
|
|
|
* original for subsequent handlers and the final write.
|
|
|
|
|
*/
|
|
|
|
|
function runBeforeMessageWrite(
|
|
|
|
|
event: PluginHookBeforeMessageWriteEvent,
|
|
|
|
|
ctx: { agentId?: string; sessionKey?: string },
|
|
|
|
|
): PluginHookBeforeMessageWriteResult | undefined {
|
|
|
|
|
const hooks = getHooksForName(registry, "before_message_write");
|
|
|
|
|
if (hooks.length === 0) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let current = event.message;
|
|
|
|
|
|
|
|
|
|
for (const hook of hooks) {
|
|
|
|
|
try {
|
|
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
|
|
|
const out = (hook.handler as any)({ ...event, message: current }, ctx) as
|
|
|
|
|
| PluginHookBeforeMessageWriteResult
|
|
|
|
|
| void
|
|
|
|
|
| Promise<unknown>;
|
|
|
|
|
|
|
|
|
|
// Guard against accidental async handlers (this hook is sync-only).
|
|
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
|
|
|
if (out && typeof (out as any).then === "function") {
|
|
|
|
|
const msg =
|
|
|
|
|
`[hooks] before_message_write handler from ${hook.pluginId} returned a Promise; ` +
|
|
|
|
|
`this hook is synchronous and the result was ignored.`;
|
|
|
|
|
if (catchErrors) {
|
|
|
|
|
logger?.warn?.(msg);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw new Error(msg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = out as PluginHookBeforeMessageWriteResult | undefined;
|
|
|
|
|
|
|
|
|
|
// If any handler blocks, return immediately.
|
|
|
|
|
if (result?.block) {
|
|
|
|
|
return { block: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If handler provided a modified message, use it for subsequent handlers.
|
|
|
|
|
if (result?.message) {
|
|
|
|
|
current = result.message;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = `[hooks] before_message_write handler from ${hook.pluginId} failed: ${String(err)}`;
|
|
|
|
|
if (catchErrors) {
|
|
|
|
|
logger?.error(msg);
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(msg, { cause: err });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If message was modified by any handler, return it.
|
|
|
|
|
if (current !== event.message) {
|
|
|
|
|
return { message: current };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:40:58 +00:00
|
|
|
// =========================================================================
|
|
|
|
|
// Session Hooks
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run session_start hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runSessionStart(
|
|
|
|
|
event: PluginHookSessionStartEvent,
|
|
|
|
|
ctx: PluginHookSessionContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("session_start", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run session_end hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runSessionEnd(
|
|
|
|
|
event: PluginHookSessionEndEvent,
|
|
|
|
|
ctx: PluginHookSessionContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("session_end", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:14:55 +01:00
|
|
|
/**
|
|
|
|
|
* Run subagent_spawning hook.
|
|
|
|
|
* Runs sequentially so channel plugins can deterministically provision session bindings.
|
|
|
|
|
*/
|
|
|
|
|
async function runSubagentSpawning(
|
|
|
|
|
event: PluginHookSubagentSpawningEvent,
|
|
|
|
|
ctx: PluginHookSubagentContext,
|
|
|
|
|
): Promise<PluginHookSubagentSpawningResult | undefined> {
|
|
|
|
|
return runModifyingHook<"subagent_spawning", PluginHookSubagentSpawningResult>(
|
|
|
|
|
"subagent_spawning",
|
|
|
|
|
event,
|
|
|
|
|
ctx,
|
|
|
|
|
mergeSubagentSpawningResult,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run subagent_delivery_target hook.
|
|
|
|
|
* Runs sequentially so channel plugins can deterministically resolve routing.
|
|
|
|
|
*/
|
|
|
|
|
async function runSubagentDeliveryTarget(
|
|
|
|
|
event: PluginHookSubagentDeliveryTargetEvent,
|
|
|
|
|
ctx: PluginHookSubagentContext,
|
|
|
|
|
): Promise<PluginHookSubagentDeliveryTargetResult | undefined> {
|
|
|
|
|
return runModifyingHook<"subagent_delivery_target", PluginHookSubagentDeliveryTargetResult>(
|
|
|
|
|
"subagent_delivery_target",
|
|
|
|
|
event,
|
|
|
|
|
ctx,
|
|
|
|
|
mergeSubagentDeliveryTargetResult,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run subagent_spawned hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runSubagentSpawned(
|
|
|
|
|
event: PluginHookSubagentSpawnedEvent,
|
|
|
|
|
ctx: PluginHookSubagentContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("subagent_spawned", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run subagent_ended hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runSubagentEnded(
|
|
|
|
|
event: PluginHookSubagentEndedEvent,
|
|
|
|
|
ctx: PluginHookSubagentContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("subagent_ended", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 05:40:58 +00:00
|
|
|
// =========================================================================
|
|
|
|
|
// Gateway Hooks
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run gateway_start hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runGatewayStart(
|
|
|
|
|
event: PluginHookGatewayStartEvent,
|
|
|
|
|
ctx: PluginHookGatewayContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("gateway_start", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run gateway_stop hook.
|
|
|
|
|
* Runs in parallel (fire-and-forget).
|
|
|
|
|
*/
|
|
|
|
|
async function runGatewayStop(
|
|
|
|
|
event: PluginHookGatewayStopEvent,
|
|
|
|
|
ctx: PluginHookGatewayContext,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
return runVoidHook("gateway_stop", event, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Utility
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if any hooks are registered for a given hook name.
|
|
|
|
|
*/
|
|
|
|
|
function hasHooks(hookName: PluginHookName): boolean {
|
|
|
|
|
return registry.typedHooks.some((h) => h.hookName === hookName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get count of registered hooks for a given hook name.
|
|
|
|
|
*/
|
|
|
|
|
function getHookCount(hookName: PluginHookName): number {
|
|
|
|
|
return registry.typedHooks.filter((h) => h.hookName === hookName).length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
// Agent hooks
|
2026-02-17 03:28:10 +01:00
|
|
|
runBeforeModelResolve,
|
|
|
|
|
runBeforePromptBuild,
|
2026-01-18 05:40:58 +00:00
|
|
|
runBeforeAgentStart,
|
2026-02-15 14:01:00 -08:00
|
|
|
runLlmInput,
|
|
|
|
|
runLlmOutput,
|
2026-01-18 05:40:58 +00:00
|
|
|
runAgentEnd,
|
|
|
|
|
runBeforeCompaction,
|
|
|
|
|
runAfterCompaction,
|
Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context
- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context
* feat: extraBootstrapFiles config with glob pattern support
Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(status): show custom memory plugins as enabled, not unavailable
The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use async fs.glob and capture pre-compaction messages
- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
before_compaction hook receives the full conversation history,
not the already-truncated list
* fix: resolve lint errors from CI (oxlint strict mode)
- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)
* fix: resolve remaining CI lint errors in workspace.ts
- Remove `| string` from WorkspaceBootstrapFileName union (made all
typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)
* fix: address Greptile review — path traversal guard + fs/promises import
- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks before workspace boundary check
Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address Greptile review — hook reliability and type safety
1. before_compaction: add compactingCount field so plugins know both
the full pre-compaction message count and the truncated count being
fed to the compaction LLM. Clarify semantics in comment.
2. loadExtraBootstrapFiles: use path.basename() for the name field
so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
instead of an invalid WorkspaceBootstrapFileName cast.
3. before_reset: fire the hook even when no session file exists.
Previously, short sessions without a persisted file would silently
skip the hook. Now fires with empty messages array so plugins
always know a reset occurred.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: validate bootstrap filenames and add compaction hook timeout
- Only load extra bootstrap files whose basename matches a recognized
workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
(plugins need messages before they're discarded) but bounded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make before_compaction non-blocking, add sessionFile to after_compaction
- before_compaction is now true fire-and-forget — no await, no timeout.
Plugins that need full conversation data should persist it themselves
and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
the full JSONL transcript asynchronously. All pre-compaction messages
are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
slow hooks (just raced past them while they continued running).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add sessionFile to before_compaction for parallel processing
The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.
sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: move bootstrap extras into bundled hook
---------
Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 06:45:45 +07:00
|
|
|
runBeforeReset,
|
2026-01-18 05:40:58 +00:00
|
|
|
// Message hooks
|
|
|
|
|
runMessageReceived,
|
|
|
|
|
runMessageSending,
|
|
|
|
|
runMessageSent,
|
|
|
|
|
// Tool hooks
|
|
|
|
|
runBeforeToolCall,
|
|
|
|
|
runAfterToolCall,
|
2026-01-19 13:11:31 +01:00
|
|
|
runToolResultPersist,
|
2026-02-16 00:36:48 -08:00
|
|
|
// Message write hooks
|
|
|
|
|
runBeforeMessageWrite,
|
2026-01-18 05:40:58 +00:00
|
|
|
// Session hooks
|
|
|
|
|
runSessionStart,
|
|
|
|
|
runSessionEnd,
|
2026-02-21 16:14:55 +01:00
|
|
|
runSubagentSpawning,
|
|
|
|
|
runSubagentDeliveryTarget,
|
|
|
|
|
runSubagentSpawned,
|
|
|
|
|
runSubagentEnded,
|
2026-01-18 05:40:58 +00:00
|
|
|
// Gateway hooks
|
|
|
|
|
runGatewayStart,
|
|
|
|
|
runGatewayStop,
|
|
|
|
|
// Utility
|
|
|
|
|
hasHooks,
|
|
|
|
|
getHookCount,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type HookRunner = ReturnType<typeof createHookRunner>;
|