Session navigation and event-driven behavior
Lena works through a long fictional review. She wants a checkpoint command that can inspect the current session and move to a deliberate place in its history.
Her obstacle is lifecycle timing: a tool executes inside an agent operation, while session navigation may need to abort, restore history or wait for the operation to settle.
She keeps navigation on a command context.
Milestone: explicit session controls
The following is a complete source-backed exercise. It is not a new Seed Desk or Review Desk stage.
Complete TypeScript exercise—save as session-desk.ts:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function sessionDesk(pi: ExtensionAPI): void {
pi.registerCommand("workbook-session", {
description: "Session controls: status | new | reload | compact | tree <id> | branch <id> | switch <file>",
async handler(args, ctx) {
const trimmed = args.trim();
const split = trimmed.indexOf(" ");
const verb = split < 0 ? trimmed : trimmed.slice(0, split);
const target = split < 0 ? "" : trimmed.slice(split + 1).trim();
if (!verb || verb === "status") {
const current = ctx.models.current();
const details = {
sessionId: ctx.sessionManager.getSessionId(),
sessionFile: ctx.sessionManager.getSessionFile() ?? null,
leafId: ctx.sessionManager.getLeafId(),
cwd: ctx.cwd,
model: current ? `${current.provider}/${current.id}` : null,
contextUsage: ctx.getContextUsage() ?? null,
asyncJobs: ctx.getAsyncJobSnapshot(),
pendingMessages: ctx.hasPendingMessages(),
};
pi.sendMessage(
{ customType: "workbook.session-status", content: JSON.stringify(details), details, display: true },
{ triggerTurn: false },
);
return;
}
await ctx.waitForIdle();
if (verb === "reload") {
await ctx.reload();
return;
}
if (verb === "compact") {
await ctx.compact("Preserve concrete decisions, unresolved questions and stable domain IDs.");
return;
}
let result: { cancelled: boolean };
if (verb === "new") {
result = await ctx.newSession();
} else if (verb === "tree" && ctx.sessionManager.getEntry(target)) {
result = await ctx.navigateTree(target, { summarize: false });
} else if (verb === "branch") {
const entry = ctx.sessionManager.getEntry(target);
if (entry?.type !== "message" || entry.message.role !== "user") {
throw new Error("branch requires an existing user-message entry ID.");
}
result = await ctx.branch(target);
} else if (verb === "switch" && target) {
if (!(await Bun.file(target).exists())) {
throw new Error("The target session file does not exist.");
}
result = await ctx.switchSession(target);
} else {
throw new Error("Use status, new, reload, compact, tree <id>, branch <id>, or switch <file>.");
}
ctx.ui.notify(result.cancelled ? "Session operation cancelled." : "Session operation completed.");
},
});
}
Human OMP slash command—after loading the exercise:
/workbook-session status
Use actual entry IDs from the current session manager/tree, not a workbook placeholder, for navigation.
The status command is a snapshot. It also appends a custom message, so its displayed leafId is the position just before that status message was appended.
What the controls mean
waitForIdle()waits through the host’s wired idle operation. It is not an exclusive lock against another caller starting work.newSession()starts a fresh transcript and returns{ cancelled }.newSession({ parentSession, setup })can associate a parent and run an initialization callback. That callback receives the full session manager for deliberate setup.branch(entryId)uses a user-message entry to create a new session file from its preceding history.navigateTree(targetId, { summarize })changes the current path in the same file.switchSession(path)restores another session.reload()reopens the current session, as discussed earlier.compact()requests context compaction.
A user-message tree target normally moves to its parent and offers that message for editing. A non-user target generally lands on the selected node. Do not equate every target ID with “include this entry as the new leaf.”
Expected checkpoint: navigating before a Seed Desk reservation changes the query result because that extension reconstructs the current branch. Field Notes’ binding-local selection does not change merely because the transcript moves.
Failure boundary: pre-navigation handlers may cancel. Missing/invalid targets fail. Summarization and compaction may need a configured model/provider and may incur costs. The exercise uses summarize: false for ordinary tree navigation.
Exercise: call ctx.newSession() from a tool by casting its context.
Answer: do not. General tool/event contexts intentionally omit these command-only navigation methods. A cast does not make the lifecycle operation safe.
Compaction is not domain persistence
Compaction changes the model’s retained conversational context. It is not a replacement for reconstructing extension state from journal entries.
The supported compaction surfaces are:
session_before_compact: cancel or supply a complete custom compaction result;session.compacting: add summary context, replace the summarizer prompt or attachpreserveData;session_compact: observe the resulting entry.
CompactOptions contains:
onComplete;onError;mode;internalGuidance.
A string passed to ctx.compact() is public custom instructions. An options object can request a one-off supported compaction mode. The supplied type commentary names soft, remote and snapcompact; use the matching host’s CompactMode rather than inventing another value.
internalGuidance is for native summarizer guidance, not the user’s customInstructions visible to the pre-compaction hook.
The general context also exposes compact(), but lifecycle-sensitive compaction is best driven from a command or a host-controlled safe boundary. Awaiting maintenance from inside work that maintenance itself must drain can create a deadlock.
Change a prompt through the supported return shape
Lena wants a temporary instruction to label fictional output, without permanently editing the base prompt.
Complete TypeScript exercise—save as fictional-turn.ts:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function fictionalTurn(pi: ExtensionAPI): void {
pi.on("before_agent_start", event => ({
systemPrompt: [
...event.systemPrompt,
"Clearly distinguish fictional workbook data from real observations.",
],
}));
}
This returns the complete replacement block array for that turn, preserving existing blocks.
There is no current native systemPromptAppend result member. The older pirate example uses that stale name; do not copy it as a working contract.
context is the per-model-call message transformation surface. Return new arrays and objects. The runner attempts a structured clone, but falls back to a shallow array copy for noncloneable data; in-place mutation is therefore a poor portability strategy.
Never remove one half of a tool-call/tool-result pair casually.
Handler timing and errors
Most event handlers run in extension order with a 30-second budget.
Important exceptions and qualifications:
tool_callusesextensionHandlers.toolCallTimeoutMs, defaulting to 30 seconds when unset or invalid.tool_callerrors/timeouts block execution.- Its budget pauses while awaiting supported human dialogs; asynchronous custom-component setup still consumes budget until the component is ready.
- Other event handlers do not inherit that same paused human-dialog budget.
session_shutdownhandlers run concurrently with a two-second per-handler cap.- Factory loading and command handlers are not automatically covered by that event-handler budget.
- Most other event-handler failures are reported and dispatch continues. A failed pre-switch handler is not automatically a veto.
- A timeout cannot preempt synchronous JavaScript or magically cancel an arbitrary detached promise. Code must honor cancellation and retire stale work.
message_start, message_update and message_end are notifications. message_end receives a detached snapshot; changing it does not rewrite the next provider request.
agent_end is also notification-only. It may indicate an already scheduled continuation through willContinue. Use session_stop for a supported stop-time continuation request, not an assumed return value from agent_end.
Exercise: throw from user_bash to prohibit a shell command.
Answer: that is not a reliable veto. Its handler errors are isolated, and the default path can continue. Use a supported interception result or an actual enforcement boundary appropriate to the operation.
Source, snapshot 2026-08-29: packages/coding-agent/src/session/agent-session.ts, session navigation and event mapping; packages/coding-agent/src/extensibility/extensions/runner.ts, #runHandlerWithTimeout, emit, emitBeforeAgentStart; packages/coding-agent/src/extensibility/shared-events.ts; packages/coding-agent/src/extensibility/extensions/compact-handler.ts.
Extensions inside those boundaries · Source chapter: extensions/session-navigation-and-event-driven-behavior. Original evidence remains scoped to its recorded snapshot.