refactor(hooks): split session events into individual typed events

Major changes:
- Replace monolithic SessionEvent with reason discriminator with individual
  event types: session_start, session_before_switch, session_switch,
  session_before_new, session_new, session_before_branch, session_branch,
  session_before_compact, session_compact, session_shutdown
- Each event has dedicated result type (SessionBeforeSwitchResult, etc.)
- HookHandler type now allows bare return statements (void in return type)
- HookAPI.on() has proper overloads for each event with correct typing

Additional fixes:
- AgentSession now always subscribes to agent in constructor (was only
  subscribing when external subscribe() called, breaking internal handlers)
- Standardize on undefined over null throughout codebase
- HookUIContext methods return undefined instead of null
- SessionManager methods return undefined instead of null
- Simplify hook exports to 'export type * from types.js'
- Add detailed JSDoc for skipConversationRestore vs cancel
- Fix createBranchedSession to rebuild index in persist mode
- newSession() now returns the session file path

Updated all example hooks, tests, and emission sites to use new event types.
This commit is contained in:
Mario Zechner 2025-12-28 20:06:20 +01:00
parent 38d65dfe59
commit d6283f99dc
43 changed files with 2129 additions and 640 deletions

View file

@ -41,7 +41,7 @@ const factory: CustomToolFactory = (pi) => {
const answer = await pi.ui.select(params.question, params.options);
if (answer === null) {
if (answer === undefined) {
return {
content: [{ type: "text", text: "User cancelled the selection" }],
details: { question: params.question, options: params.options, answer: null },

View file

@ -8,9 +8,7 @@
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
if (event.reason !== "shutdown") return;
pi.on("session_shutdown", async (_event, ctx) => {
// Check for uncommitted changes
const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]);

View file

@ -2,63 +2,57 @@
* Confirm Destructive Actions Hook
*
* Prompts for confirmation before destructive session actions (clear, switch, branch).
* Demonstrates how to cancel session events using the before_* variants.
* Demonstrates how to cancel session events using the before_* events.
*/
import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
// Only handle before_* events (the ones that can be cancelled)
if (event.reason === "before_new") {
if (!ctx.hasUI) return;
pi.on("session_before_new", async (_event, ctx) => {
if (!ctx.hasUI) return;
const confirmed = await ctx.ui.confirm("Clear session?", "This will delete all messages in the current session.");
if (!confirmed) {
ctx.ui.notify("Clear cancelled", "info");
return { cancel: true };
}
});
pi.on("session_before_switch", async (_event, ctx) => {
if (!ctx.hasUI) return;
// Check if there are unsaved changes (messages since last assistant response)
const entries = ctx.sessionManager.getEntries();
const hasUnsavedWork = entries.some(
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
);
if (hasUnsavedWork) {
const confirmed = await ctx.ui.confirm(
"Clear session?",
"This will delete all messages in the current session.",
"Switch session?",
"You have messages in the current session. Switch anyway?",
);
if (!confirmed) {
ctx.ui.notify("Clear cancelled", "info");
return { cancel: true };
}
}
if (event.reason === "before_switch") {
if (!ctx.hasUI) return;
// Check if there are unsaved changes (messages since last assistant response)
const entries = ctx.sessionManager.getEntries();
const hasUnsavedWork = entries.some(
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
);
if (hasUnsavedWork) {
const confirmed = await ctx.ui.confirm(
"Switch session?",
"You have messages in the current session. Switch anyway?",
);
if (!confirmed) {
ctx.ui.notify("Switch cancelled", "info");
return { cancel: true };
}
}
}
if (event.reason === "before_branch") {
if (!ctx.hasUI) return;
const choice = await ctx.ui.select(`Branch from turn ${event.targetTurnIndex}?`, [
"Yes, create branch",
"No, stay in current session",
]);
if (choice !== "Yes, create branch") {
ctx.ui.notify("Branch cancelled", "info");
ctx.ui.notify("Switch cancelled", "info");
return { cancel: true };
}
}
});
pi.on("session_before_branch", async (event, ctx) => {
if (!ctx.hasUI) return;
const choice = await ctx.ui.select(`Branch from turn ${event.entryIndex}?`, [
"Yes, create branch",
"No, stay in current session",
]);
if (choice !== "Yes, create branch") {
ctx.ui.notify("Branch cancelled", "info");
return { cancel: true };
}
});
}

View file

@ -18,9 +18,7 @@ import { convertToLlm } from "@mariozechner/pi-coding-agent";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
if (event.reason !== "before_compact") return;
pi.on("session_before_compact", async (event, ctx) => {
ctx.ui.notify("Custom compaction hook triggered", "info");
const { preparation, previousCompactions, signal } = event;

View file

@ -5,47 +5,55 @@
* Useful to ensure work is committed before switching context.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI, HookEventContext } from "@mariozechner/pi-coding-agent/hooks";
async function checkDirtyRepo(
pi: HookAPI,
ctx: HookEventContext,
action: string,
): Promise<{ cancel: boolean } | undefined> {
// Check for uncommitted changes
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
if (code !== 0) {
// Not a git repo, allow the action
return;
}
const hasChanges = stdout.trim().length > 0;
if (!hasChanges) {
return;
}
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { cancel: true };
}
// Count changed files
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
"Yes, proceed anyway",
"No, let me commit first",
]);
if (choice !== "Yes, proceed anyway") {
ctx.ui.notify("Commit your changes first", "warning");
return { cancel: true };
}
}
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
// Only guard destructive actions
if (event.reason !== "before_new" && event.reason !== "before_switch" && event.reason !== "before_branch") {
return;
}
pi.on("session_before_new", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "new session");
});
// Check for uncommitted changes
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
pi.on("session_before_switch", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "switch session");
});
if (code !== 0) {
// Not a git repo, allow the action
return;
}
const hasChanges = stdout.trim().length > 0;
if (!hasChanges) {
return;
}
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { cancel: true };
}
// Count changed files
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
const action =
event.reason === "before_new" ? "new session" : event.reason === "before_switch" ? "switch session" : "branch";
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
"Yes, proceed anyway",
"No, let me commit first",
]);
if (choice !== "Yes, proceed anyway") {
ctx.ui.notify("Commit your changes first", "warning");
return { cancel: true };
}
pi.on("session_before_branch", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "branch");
});
}

View file

@ -12,9 +12,7 @@ import * as fs from "node:fs";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
if (event.reason !== "start") return;
pi.on("session_start", async (_event, ctx) => {
const triggerFile = "/tmp/agent-trigger.txt";
fs.watch(triggerFile, () => {

View file

@ -19,11 +19,8 @@ export default function (pi: HookAPI) {
}
});
pi.on("session", async (event, ctx) => {
// Only handle before_branch events
if (event.reason !== "before_branch") return;
const ref = checkpoints.get(event.targetTurnIndex);
pi.on("session_before_branch", async (event, ctx) => {
const ref = checkpoints.get(event.entryIndex);
if (!ref) return;
if (!ctx.hasUI) {