feat: add before_message_write plugin hook

Synchronous hook that lets plugins inspect and optionally block messages
before they are written to the session JSONL file. Primary use case is
private mode... when enabled, the plugin returns { block: true } and the
message never gets persisted.

The hook runs on the hot path (synchronous, like tool_result_persist).
Handlers execute sequentially in priority order. If any handler returns
{ block: true }, the write is skipped immediately. Handlers can also
return a modified message to write instead of the original.

Changes:
- src/plugins/types.ts: add hook name, event/result types, handler map entry
- src/plugins/hooks.ts: add runBeforeMessageWrite() following tool_result_persist pattern
- src/agents/session-tool-result-guard.ts: invoke hook before every originalAppend() call
- src/agents/session-tool-result-guard-wrapper.ts: wire hook runner to the guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Parker Todd Brooks
2026-02-16 00:36:48 -08:00
committed by Peter Steinberger
parent 94eecaa446
commit 15fe87e6b7
4 changed files with 148 additions and 5 deletions

View File

@@ -36,6 +36,8 @@ import type {
PluginHookToolResultPersistContext,
PluginHookToolResultPersistEvent,
PluginHookToolResultPersistResult,
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
} from "./types.js";
// Re-export types for consumers
@@ -61,6 +63,8 @@ export type {
PluginHookToolResultPersistContext,
PluginHookToolResultPersistEvent,
PluginHookToolResultPersistResult,
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
PluginHookSessionContext,
PluginHookSessionStartEvent,
PluginHookSessionEndEvent,
@@ -410,6 +414,84 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return { message: current };
}
// =========================================================================
// 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;
}
// =========================================================================
// Session Hooks
// =========================================================================
@@ -497,6 +579,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runBeforeToolCall,
runAfterToolCall,
runToolResultPersist,
// Message write hooks
runBeforeMessageWrite,
// Session hooks
runSessionStart,
runSessionEnd,

View File

@@ -309,6 +309,7 @@ export type PluginHookName =
| "before_tool_call"
| "after_tool_call"
| "tool_result_persist"
| "before_message_write"
| "session_start"
| "session_end"
| "gateway_start"
@@ -493,6 +494,18 @@ export type PluginHookToolResultPersistResult = {
message?: AgentMessage;
};
// before_message_write hook
export type PluginHookBeforeMessageWriteEvent = {
message: AgentMessage;
sessionKey?: string;
agentId?: string;
};
export type PluginHookBeforeMessageWriteResult = {
block?: boolean; // If true, message is NOT written to JSONL
message?: AgentMessage; // Optional: modified message to write instead
};
// Session context
export type PluginHookSessionContext = {
agentId?: string;
@@ -575,6 +588,10 @@ export type PluginHookHandlerMap = {
event: PluginHookToolResultPersistEvent,
ctx: PluginHookToolResultPersistContext,
) => PluginHookToolResultPersistResult | void;
before_message_write: (
event: PluginHookBeforeMessageWriteEvent,
ctx: { agentId?: string; sessionKey?: string },
) => PluginHookBeforeMessageWriteResult | void;
session_start: (
event: PluginHookSessionStartEvent,
ctx: PluginHookSessionContext,