import type { PilotDeckHookEffect, PilotDeckLifecycleError } from "../../../lifecycle/protocol/effects.js";
import { matchHookMatcher } from "../config/matchHook.js";
import { matchHookCondition } from "../config/matchHookCondition.js";
import type { PilotDeckHookEvent } from "../protocol/events.js";
import type { PilotDeckHookInput } from "../protocol/input.js";
import type { PilotDeckHookOutput, PilotDeckHookSyncOutput } from "../protocol/output.js";
import type { PilotDeckHookCommand, PilotDeckHooksSettings } from "../protocol/settings.js";
import { CommandHookExecutor, PILOTDECK_SESSION_END_HOOK_TIMEOUT_MS } from "./CommandHookExecutor.js";
import { PromptHookExecutor } from "./PromptHookExecutor.js";
import { HttpHookExecutor } from "./HttpHookExecutor.js";
import { AgentHookExecutor } from "./AgentHookExecutor.js";
import { AsyncHookRegistry } from "./AsyncHookRegistry.js";
import { CallbackHookExecutor } from "./CallbackHookExecutor.js";
import { HookExecutionEventBus, type PilotDeckHookExecutionEvent } from "../events/HookExecutionEventBus.js";
export type HookRuntimeRunInput = {
event: PilotDeckHookEvent;
hookInput: PilotDeckHookInput;
matchQuery?: string;
cwd: string;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
};
export type HookRuntimeRunResult = {
effects: PilotDeckHookEffect[];
events: PilotDeckHookExecutionEvent[];
blockingErrors: PilotDeckLifecycleError[];
nonBlockingErrors: PilotDeckLifecycleError[];
};
export class HookRuntime {
constructor(
private readonly settings: PilotDeckHooksSettings = {},
private readonly commandExecutor = new CommandHookExecutor(),
private readonly eventBus = new HookExecutionEventBus(),
private readonly asyncRegistry = new AsyncHookRegistry(),
private readonly promptExecutor = new PromptHookExecutor(),
private readonly httpExecutor = new HttpHookExecutor(),
private readonly agentExecutor = new AgentHookExecutor(),
private readonly callbackExecutor = new CallbackHookExecutor(),
) {}
* Expose the {@link CallbackHookExecutor} so the caller can register
* per-process callbacks (e.g. the gateway's interactive permission
* hook) before the runtime starts dispatching events.
*/
getCallbackExecutor(): CallbackHookExecutor {
return this.callbackExecutor;
}
async run(input: HookRuntimeRunInput): Promise<HookRuntimeRunResult> {
const effects: PilotDeckHookEffect[] = [];
const events: PilotDeckHookExecutionEvent[] = [];
const blockingErrors: PilotDeckLifecycleError[] = [];
const nonBlockingErrors: PilotDeckLifecycleError[] = [];
for (const { matcher, hook } of this.matchHooks(input)) {
const hookName = matcher.pluginName ? `${matcher.pluginName}:${hook.type}` : hook.type;
const started: PilotDeckHookExecutionEvent = {
type: "started",
hookName,
hookEvent: input.event,
};
events.push(started);
this.eventBus.emit(started);
const result = await this.executeHook(hook, input, matcher.pluginRoot);
const response: PilotDeckHookExecutionEvent = {
type: "response",
hookName,
hookEvent: input.event,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
outcome: result.outcome,
};
events.push(response);
this.eventBus.emit(response);
if (result.output.type === "async") {
this.asyncRegistry.register({
id: `${hookName}:${Date.now()}`,
startedAt: new Date(),
hookName,
hookEvent: input.event,
stdout: result.stdout,
stderr: result.stderr,
responseDelivered: false,
asyncRewake: hook.type === "command" ? hook.asyncRewake : undefined,
});
}
if (result.outcome === "blocking") {
const message = result.stderr || result.stdout || "Hook blocked execution.";
blockingErrors.push({ code: "hook_blocking_error", message, hookName, exitCode: result.exitCode });
effects.push({ type: "block", reason: message });
} else if (result.outcome === "non_blocking_error" || result.outcome === "timeout" || result.outcome === "cancelled") {
nonBlockingErrors.push({
code: result.outcome === "cancelled" ? "hook_cancelled" : "hook_non_blocking_error",
message: result.stderr || result.stdout || `Hook ended with outcome ${result.outcome}.`,
hookName,
exitCode: result.exitCode,
});
}
effects.push(...effectsFromHookOutput(result.output, hookName));
}
return { effects, events, blockingErrors, nonBlockingErrors };
}
collectAsyncResponses(): ReturnType<AsyncHookRegistry["collectResponses"]> {
return this.asyncRegistry.collectResponses();
}
removeDeliveredAsyncResponses(): void {
this.asyncRegistry.removeDelivered();
}
private *matchHooks(input: HookRuntimeRunInput): Generator<{
matcher: NonNullable<PilotDeckHooksSettings[PilotDeckHookEvent]>[number];
hook: PilotDeckHookCommand;
}> {
for (const matcher of this.settings[input.event] ?? []) {
if (!matchHookMatcher(matcher.matcher, input.matchQuery)) {
continue;
}
for (const hook of matcher.hooks) {
if (
matchHookCondition(hook.if, {
toolName: typeof input.hookInput.toolName === "string" ? input.hookInput.toolName : undefined,
toolInput: input.hookInput.toolInput,
})
) {
yield { matcher, hook };
}
}
}
}
private executeHook(
hook: PilotDeckHookCommand,
input: HookRuntimeRunInput,
pluginRoot: string | undefined,
) {
switch (hook.type) {
case "command":
return this.commandExecutor.execute({
hook,
hookInput: input.hookInput,
cwd: pluginRoot ?? input.cwd,
env: input.env,
signal: input.signal,
timeoutMs: input.event === "SessionEnd" ? PILOTDECK_SESSION_END_HOOK_TIMEOUT_MS : undefined,
});
case "prompt":
return this.promptExecutor.execute({ hook, hookInput: input.hookInput, signal: input.signal });
case "http":
return this.httpExecutor.execute({ hook, hookInput: input.hookInput, env: input.env, signal: input.signal });
case "agent":
return this.agentExecutor.execute({ hook, hookInput: input.hookInput, signal: input.signal });
case "callback":
return this.callbackExecutor.execute({ hook, hookInput: input.hookInput, signal: input.signal });
}
}
}
function effectsFromHookOutput(output: PilotDeckHookOutput, hookName: string): PilotDeckHookEffect[] {
if (output.type === "async") {
return [];
}
const effects: PilotDeckHookEffect[] = [];
if (output.systemMessage) {
effects.push({ type: "system_message", content: output.systemMessage });
}
if (isBlockingOutput(output)) {
effects.push({
type: "block",
reason: output.reason ?? output.stopReason ?? "Hook blocked execution.",
stopReason: output.stopReason,
});
}
if (output.specific) {
const specific = output.specific;
if (specific.additionalContext) {
effects.push({ type: "additional_context", content: specific.additionalContext, source: hookName });
}
if (specific.initialUserMessage) {
effects.push({ type: "initial_user_message", message: specific.initialUserMessage });
}
if (specific.watchPaths?.length) {
effects.push({ type: "watch_paths", paths: specific.watchPaths });
}
if (specific.worktreePath) {
effects.push({ type: "worktree_path", path: specific.worktreePath });
}
if (specific.permissionDecision) {
effects.push({
type: "permission_decision",
behavior: specific.permissionDecision,
reason: specific.permissionDecisionReason,
});
}
if (specific.updatedInput) {
effects.push({ type: "updated_tool_input", input: specific.updatedInput });
}
if (specific.updatedMCPToolOutput !== undefined) {
effects.push({ type: "updated_mcp_tool_output", output: specific.updatedMCPToolOutput });
}
if (specific.decision) {
effects.push({ type: "permission_request_result", result: specific.decision });
}
if (specific.retry) {
effects.push({ type: "retry_permission_denied" });
}
}
return effects;
}
function isBlockingOutput(output: PilotDeckHookSyncOutput): boolean {
return output.continue === false || output.decision === "block";
}