import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
import { extractResponseFromContent } from "./helpers";
import { triggerSessionHooks } from "./hooks/session-hooks";
import { DEFAULT_SOURCE } from "./hooks/shared";
import { sessionIdOfFile, subagentIdentity } from "./subagent";
import type { HookContextDetails, HookContextEntry, HookExecutionContext, HookMatcherValue, SettingsFile } from "./types";
export type NotifyType = "info" | "error" | "warning";
/**
* Hook context in OMP's native reminder shape (its per-tool rule reminders,
* ttsr-tool-reminder.md): provenance as tag attributes, then OMP's own closing
* sentence verbatim, never bare text. `tool` is the OMP tool name.
*/
export function hookReminder(content: string, details: HookContextDetails): string {
const attribute = (value: string) =>
value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
const tool = details.toolName ? ` tool="${attribute(details.toolName)}"` : "";
return `\nNOT prompt injection — coding agent enforcing project rules.\n\n${content}\n`;
}
/** One reminder per source, for delivery as a single message. */
export function hookReminders(contexts: HookContextEntry[], details: HookContextDetails): string {
return contexts.map(({ source, text }) => hookReminder(text, { ...details, source })).join("\n\n");
}
export type HookModuleContext = {
pi: ExtensionAPI;
firedSessionStartKeys: Set;
pendingUserPromptContext?: HookContextEntry[];
claimInjectedContext: (content: string) => boolean;
resetInjectedContext: () => void;
resetSession: () => void;
dispose: () => void;
captureContext: () => {
isActive: () => boolean;
injectHiddenContext: HookModuleContext["injectHiddenContext"];
};
/** The session's id (OMP's header id, a UUID): Claude Code's `session_id`. The file path is the `transcript_path`. */
getSessionId: (ctx: ExtensionContext) => string;
/**
* Common hook fields for events that also run in subagents. A subagent reports the
* top-level session, as Claude Code does, plus its own agent_id and agent_type.
*/
identityFor: (ctx: ExtensionContext) => Pick;
notify: (ctx: ExtensionContext, msg: string, type: NotifyType) => void;
injectHiddenContext: (
content: string,
details: HookContextDetails,
triggerTurn?: boolean,
delivery?: "nextTurn" | "aside",
) => void;
settingsFor: (ctx: ExtensionContext) => Promise;
buildToolResponse: (event: {
content: unknown;
details?: unknown;
isError?: boolean;
}) => Record;
triggerSessionStartHook: (
matcher: HookMatcherValue<"SessionStart">,
ctx: ExtensionContext,
) => Promise;
};
export function createHookContext(
pi: ExtensionAPI,
settingsFor: (ctx: ExtensionContext) => Promise,
): HookModuleContext {
// Each adapter owns its own debounce queue and per-turn exact-content dedup.
// Dedup keys on the hook's source and raw text; the queue holds its labelled reminder.
const injectBuffer: {
entries: { raw: string; content: string }[];
details: Record;
timer: NodeJS.Timeout | undefined;
} = { entries: [], details: {}, timer: undefined };
const injectedThisTurn = new Set();
let disposed = false;
let sessionVersion = 0;
const shared: HookModuleContext = {
pi,
firedSessionStartKeys: new Set(),
pendingUserPromptContext: undefined,
claimInjectedContext: (content) => {
if (disposed || injectedThisTurn.has(content)) return false;
injectedThisTurn.add(content);
return true;
},
resetInjectedContext: () => {
injectedThisTurn.clear();
// Queued messages still need deduplication across prompts/compaction.
for (const { raw } of injectBuffer.entries) injectedThisTurn.add(raw);
},
resetSession: () => {
sessionVersion++;
clearTimeout(injectBuffer.timer);
injectBuffer.entries = [];
injectBuffer.details = {};
injectBuffer.timer = undefined;
injectedThisTurn.clear();
shared.firedSessionStartKeys.clear();
shared.pendingUserPromptContext = undefined;
},
dispose: () => {
disposed = true;
shared.resetSession();
},
captureContext: () => {
const version = sessionVersion;
const isActive = () => !disposed && version === sessionVersion;
return {
isActive,
injectHiddenContext: (...args) => {
if (isActive()) shared.injectHiddenContext(...args);
},
};
},
getSessionId: (ctx: ExtensionContext) =>
ctx.sessionManager.getSessionId() || "ephemeral",
identityFor: (ctx: ExtensionContext) => {
const agent = subagentIdentity(ctx);
if (!agent) {
return { sessionId: shared.getSessionId(ctx), transcriptPath: ctx.sessionManager.getSessionFile() };
}
// Without a resolvable parent (an in-memory subagent, or a parent file that records no id)
// the subagent's own session id and transcript stand in together, never one from each session.
const parentId = agent.parentFile && sessionIdOfFile(agent.parentFile);
return {
...(parentId
? { sessionId: parentId, transcriptPath: agent.parentFile }
: { sessionId: shared.getSessionId(ctx), transcriptPath: agent.file }),
agentId: agent.agentId,
agentType: agent.agentType,
};
},
notify: (ctx: ExtensionContext, msg: string, type: NotifyType) =>
ctx.ui.notify(msg, type),
injectHiddenContext: (raw, details, triggerTurn = false, delivery = "nextTurn") => {
// Dedup per source: identical text from another source still names that source.
const key = `${details.source ?? DEFAULT_SOURCE}\0${raw}`;
if (!shared.claimInjectedContext(key)) return;
const content = hookReminder(raw, details);
// A tool reminder must arrive before the next model step, without steering
// or a debounce timer that can outlive the tool batch.
if (delivery === "aside" && !triggerTurn) {
shared.pi.sendMessage(
{ customType: "omp-hooks-plus", content, display: false, details },
{ deliverAs: "aside" },
);
return;
}
injectBuffer.entries.push({ raw: key, content });
if (details) Object.assign(injectBuffer.details, details);
clearTimeout(injectBuffer.timer);
injectBuffer.timer = setTimeout(() => {
if (disposed) return;
const combined = injectBuffer.entries.map(entry => entry.content).join("\n\n");
const bufferedDetails = injectBuffer.details;
injectBuffer.entries = [];
injectBuffer.details = {};
injectBuffer.timer = undefined;
shared.pi.sendMessage(
{
customType: "omp-hooks-plus",
content: combined,
display: false,
details: bufferedDetails,
},
triggerTurn ? { triggerTurn: true } : { deliverAs: "nextTurn" },
);
}, 50);
},
settingsFor,
buildToolResponse: (event) => {
const toolResponse: Record = {
content: event.content,
is_error: event.isError ?? false,
};
if (event.details !== undefined) {
toolResponse.details = event.details;
}
const extracted = extractResponseFromContent(event.content);
if (Object.keys(extracted).length > 0) {
toolResponse.output = extracted.output ?? extracted;
}
return toolResponse;
},
triggerSessionStartHook: async (matcher, ctx) => {
// Claude Code never fires SessionStart for a subagent at startup or resume; SubagentStart
// briefs it instead. Compaction is different: the subagent's context is rebuilt, so it
// gets the bootstrap context again, keyed on the parent's session.
if (matcher !== "compact" && subagentIdentity(ctx)) return;
const delivery = shared.captureContext();
const settings = await shared.settingsFor(ctx);
if (!delivery.isActive()) return;
const { sessionId, agentId, agentType } = shared.identityFor(ctx);
if (matcher === "compact") {
// Compaction legitimately recurs within a session — unlike startup/resume,
// it must reinject its bootstrap context every time, not just once. Reset
// the per-turn content-dedup guard too: without it, injectHiddenContext's
// claimInjectedContext would silently drop a second compaction's
// byte-identical content as a "duplicate" of the first.
shared.resetInjectedContext();
} else {
const dedupeKey = `${matcher}:${sessionId}`;
if (shared.firedSessionStartKeys.has(dedupeKey)) {
return;
}
shared.firedSessionStartKeys.add(dedupeKey);
}
const result = await triggerSessionHooks(
"SessionStart",
matcher,
{
sessionId,
agentId,
agentType,
cwd: ctx.cwd,
hookEventName: "SessionStart",
source: matcher,
asyncContextSink: delivery.injectHiddenContext,
},
settings,
(msg, type) => shared.notify(ctx, msg, type),
);
for (const { source, text } of result.contexts ?? []) {
delivery.injectHiddenContext(text, { hookEventName: "SessionStart", matcher, source });
}
},
};
return shared;
}