diff --git a/packages/coding-agent/examples/custom-tools/question/index.ts b/packages/coding-agent/examples/custom-tools/question/index.ts
index 23de95b7..76c068ca 100644
--- a/packages/coding-agent/examples/custom-tools/question/index.ts
+++ b/packages/coding-agent/examples/custom-tools/question/index.ts
@@ -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 },
diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
index 0f3ef871..bfcc37f7 100644
--- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
+++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
@@ -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"]);
diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts
index 81e32eb5..75c5ee0c 100644
--- a/packages/coding-agent/examples/hooks/confirm-destructive.ts
+++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts
@@ -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 };
+ }
+ });
}
diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts
index 559cd682..f912a3d6 100644
--- a/packages/coding-agent/examples/hooks/custom-compaction.ts
+++ b/packages/coding-agent/examples/hooks/custom-compaction.ts
@@ -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;
diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts
index 852ff006..134667e3 100644
--- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts
+++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts
@@ -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");
});
}
diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts
index 74f6314b..4363bdce 100644
--- a/packages/coding-agent/examples/hooks/file-trigger.ts
+++ b/packages/coding-agent/examples/hooks/file-trigger.ts
@@ -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, () => {
diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts
index a69cadc8..87c8f0b5 100644
--- a/packages/coding-agent/examples/hooks/git-checkpoint.ts
+++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts
@@ -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) {
diff --git a/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html b/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html
new file mode 100644
index 00000000..c8f5a813
--- /dev/null
+++ b/packages/coding-agent/pi-session-2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.html
@@ -0,0 +1,496 @@
+
+
+
+
+
+ Session Export - 2025-12-28T17-08-16-800Z_d1555962-63f5-402c-b6e5-24fa5d528fb2.jsonl
+
+
+
+
+
+
+
+
+
+
+
+
+
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
+
+Available tools:
+- read: Read file contents
+- bash: Execute bash commands (ls, grep, find, etc.)
+- edit: Make surgical edits to files (find exact text and replace)
+- write: Create or overwrite files
+
+Guidelines:
+- Use bash for file operations like ls, grep, find
+- Use read to examine files before editing. You must use this tool instead of cat or sed.
+- Use edit for precise changes (old text must match exactly)
+- Use write only for new files or complete rewrites
+- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
+- Be concise in your responses
+- Show file paths clearly when working with files
+
+Documentation:
+- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md
+- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs
+- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)
+
+# Project Context
+
+The following project context files have been loaded:
+
+## /Users/badlogic/workspaces/pi-mono/AGENTS.md
+
+# Development Rules
+
+## First Message
+If the user did not give you a concrete task in their first message,
+read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel.
+- packages/ai/README.md
+- packages/tui/README.md
+- packages/agent/README.md
+- packages/coding-agent/README.md
+- packages/mom/README.md
+- packages/pods/README.md
+- packages/web-ui/README.md
+
+## Code Quality
+- No `any` types unless absolutely necessary
+- Check node_modules for external API type definitions instead of guessing
+- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports.
+- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
+- Always ask before removing functionality or code that appears to be intentional
+
+## Commands
+- After code changes: `npm run check` (get full output, no tail)
+- NEVER run: `npm run dev`, `npm run build`, `npm test`
+- Only run specific tests if user instructs: `npm test -- test/specific.test.ts`
+- NEVER commit unless user asks
+
+## GitHub Issues
+When reading issues:
+- Always read all comments on the issue
+
+When creating issues:
+- Add `pkg:*` labels to indicate which package(s) the issue affects
+ - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui`
+- If an issue spans multiple packages, add all relevant labels
+
+When closing issues via commit:
+- Include `fixes #<number>` or `closes #<number>` in the commit message
+- This automatically closes the issue when the commit is merged
+
+## Tools
+- GitHub CLI for issues/PRs
+- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui
+- TUI interaction: use tmux
+
+## Style
+- Keep answers short and concise
+- No emojis in commits, issues, PR comments, or code
+- No fluff or cheerful filler text
+- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
+
+## Changelog
+Location: `packages/*/CHANGELOG.md` (each package has its own)
+
+### Format
+Use these sections under `## [Unreleased]`:
+- `### Breaking Changes` - API changes requiring migration
+- `### Added` - New features
+- `### Changed` - Changes to existing functionality
+- `### Fixed` - Bug fixes
+- `### Removed` - Removed features
+
+### Rules
+- New entries ALWAYS go under `## [Unreleased]` section
+- NEVER modify already-released version sections (e.g., `## [0.12.2]`)
+- Each version section is immutable once released
+
+### Attribution
+- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))`
+- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))`
+
+## Releasing
+
+1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md
+
+2. **Run release script**:
+ ```bash
+ npm run release:patch # Bug fixes
+ npm run release:minor # New features
+ npm run release:major # Breaking changes
+ ```
+
+The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections.
+
+### Tool Usage
+**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads).
+
+
+Current date and time: Sunday, December 28, 2025 at 06:08:16 PM GMT+1
+Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
+
+
+
+
+
+
06:08:16 PM
Hello! I'm Claude Code, here to help you with coding tasks in this pi-mono monorepo.
+
Since you haven't given me a specific task yet, let me read the main README to understand the project better, then I can ask which module(s) you'd like to work on.
+
06:08:20 PM
Great! This is a monorepo with several packages for building AI agents and managing LLM deployments.
+
Which module(s) would you like to work on?
+
+- ai - Unified multi-provider LLM API
+- agent - Agent runtime with tool calling and state management
+- coding-agent - Interactive coding agent CLI
+- mom - Slack bot that delegates to the coding agent
+- tui - Terminal UI library
+- web-ui - Web components for AI chat interfaces
+- pods - CLI for managing vLLM deployments
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html b/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html
new file mode 100644
index 00000000..13c3df87
--- /dev/null
+++ b/packages/coding-agent/pi-session-2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.html
@@ -0,0 +1,496 @@
+
+
+
+
+
+ Session Export - 2025-12-28T18-08-12-562Z_6646a9e5-779b-4f81-8d45-d8d32f99aa25.jsonl
+
+
+
+
+
+
+
+
+
+
+
+
+
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
+
+Available tools:
+- read: Read file contents
+- bash: Execute bash commands (ls, grep, find, etc.)
+- edit: Make surgical edits to files (find exact text and replace)
+- write: Create or overwrite files
+
+Guidelines:
+- Use bash for file operations like ls, grep, find
+- Use read to examine files before editing. You must use this tool instead of cat or sed.
+- Use edit for precise changes (old text must match exactly)
+- Use write only for new files or complete rewrites
+- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
+- Be concise in your responses
+- Show file paths clearly when working with files
+
+Documentation:
+- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md
+- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs
+- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)
+
+# Project Context
+
+The following project context files have been loaded:
+
+## /Users/badlogic/workspaces/pi-mono/AGENTS.md
+
+# Development Rules
+
+## First Message
+If the user did not give you a concrete task in their first message,
+read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel.
+- packages/ai/README.md
+- packages/tui/README.md
+- packages/agent/README.md
+- packages/coding-agent/README.md
+- packages/mom/README.md
+- packages/pods/README.md
+- packages/web-ui/README.md
+
+## Code Quality
+- No `any` types unless absolutely necessary
+- Check node_modules for external API type definitions instead of guessing
+- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports.
+- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
+- Always ask before removing functionality or code that appears to be intentional
+
+## Commands
+- After code changes: `npm run check` (get full output, no tail)
+- NEVER run: `npm run dev`, `npm run build`, `npm test`
+- Only run specific tests if user instructs: `npm test -- test/specific.test.ts`
+- NEVER commit unless user asks
+
+## GitHub Issues
+When reading issues:
+- Always read all comments on the issue
+
+When creating issues:
+- Add `pkg:*` labels to indicate which package(s) the issue affects
+ - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui`
+- If an issue spans multiple packages, add all relevant labels
+
+When closing issues via commit:
+- Include `fixes #<number>` or `closes #<number>` in the commit message
+- This automatically closes the issue when the commit is merged
+
+## Tools
+- GitHub CLI for issues/PRs
+- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui
+- TUI interaction: use tmux
+
+## Style
+- Keep answers short and concise
+- No emojis in commits, issues, PR comments, or code
+- No fluff or cheerful filler text
+- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
+
+## Changelog
+Location: `packages/*/CHANGELOG.md` (each package has its own)
+
+### Format
+Use these sections under `## [Unreleased]`:
+- `### Breaking Changes` - API changes requiring migration
+- `### Added` - New features
+- `### Changed` - Changes to existing functionality
+- `### Fixed` - Bug fixes
+- `### Removed` - Removed features
+
+### Rules
+- New entries ALWAYS go under `## [Unreleased]` section
+- NEVER modify already-released version sections (e.g., `## [0.12.2]`)
+- Each version section is immutable once released
+
+### Attribution
+- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))`
+- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))`
+
+## Releasing
+
+1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md
+
+2. **Run release script**:
+ ```bash
+ npm run release:patch # Bug fixes
+ npm run release:minor # New features
+ npm run release:major # Breaking changes
+ ```
+
+The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections.
+
+### Tool Usage
+**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads).
+
+
+Current date and time: Sunday, December 28, 2025 at 07:08:12 PM GMT+1
+Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
+
+
+
+
+
+
07:08:12 PM
Hello! I'm Claude Code, ready to help with coding tasks in this pi-mono project.
+
Since you haven't given me a specific task yet, let me first read the main README to understand the project structure:
+
07:08:16 PM
Great! I can see this is a monorepo for building AI agents and managing LLM deployments with several packages.
+
Which module(s) would you like to work on?
+
+- @mariozechner/pi-ai - Unified multi-provider LLM API
+- @mariozechner/pi-agent-core - Agent runtime with tool calling
+- @mariozechner/pi-coding-agent - Interactive coding agent CLI (current directory)
+- @mariozechner/pi-mom - Slack bot integration
+- @mariozechner/pi-tui - Terminal UI library
+- @mariozechner/pi-web-ui - Web components for chat interfaces
+- @mariozechner/pi-pods - vLLM deployment CLI
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html b/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html
new file mode 100644
index 00000000..40978803
--- /dev/null
+++ b/packages/coding-agent/pi-session-2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.html
@@ -0,0 +1,495 @@
+
+
+
+
+
+ Session Export - 2025-12-28T19-05-42-296Z_7225b2a7-7612-440d-84f1-fbcd8037d2f2.jsonl
+
+
+
+
+
+
+
+
+
+
+
+
+
You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
+
+Available tools:
+- read: Read file contents
+- bash: Execute bash commands (ls, grep, find, etc.)
+- edit: Make surgical edits to files (find exact text and replace)
+- write: Create or overwrite files
+
+Guidelines:
+- Use bash for file operations like ls, grep, find
+- Use read to examine files before editing. You must use this tool instead of cat or sed.
+- Use edit for precise changes (old text must match exactly)
+- Use write only for new files or complete rewrites
+- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
+- Be concise in your responses
+- Show file paths clearly when working with files
+
+Documentation:
+- Main documentation: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/README.md
+- Additional docs: /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs
+- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)
+
+# Project Context
+
+The following project context files have been loaded:
+
+## /Users/badlogic/workspaces/pi-mono/AGENTS.md
+
+# Development Rules
+
+## First Message
+If the user did not give you a concrete task in their first message,
+read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel.
+- packages/ai/README.md
+- packages/tui/README.md
+- packages/agent/README.md
+- packages/coding-agent/README.md
+- packages/mom/README.md
+- packages/pods/README.md
+- packages/web-ui/README.md
+
+## Code Quality
+- No `any` types unless absolutely necessary
+- Check node_modules for external API type definitions instead of guessing
+- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports.
+- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
+- Always ask before removing functionality or code that appears to be intentional
+
+## Commands
+- After code changes: `npm run check` (get full output, no tail)
+- NEVER run: `npm run dev`, `npm run build`, `npm test`
+- Only run specific tests if user instructs: `npm test -- test/specific.test.ts`
+- NEVER commit unless user asks
+
+## GitHub Issues
+When reading issues:
+- Always read all comments on the issue
+
+When creating issues:
+- Add `pkg:*` labels to indicate which package(s) the issue affects
+ - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui`
+- If an issue spans multiple packages, add all relevant labels
+
+When closing issues via commit:
+- Include `fixes #<number>` or `closes #<number>` in the commit message
+- This automatically closes the issue when the commit is merged
+
+## Tools
+- GitHub CLI for issues/PRs
+- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui
+- TUI interaction: use tmux
+
+## Style
+- Keep answers short and concise
+- No emojis in commits, issues, PR comments, or code
+- No fluff or cheerful filler text
+- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
+
+## Changelog
+Location: `packages/*/CHANGELOG.md` (each package has its own)
+
+### Format
+Use these sections under `## [Unreleased]`:
+- `### Breaking Changes` - API changes requiring migration
+- `### Added` - New features
+- `### Changed` - Changes to existing functionality
+- `### Fixed` - Bug fixes
+- `### Removed` - Removed features
+
+### Rules
+- New entries ALWAYS go under `## [Unreleased]` section
+- NEVER modify already-released version sections (e.g., `## [0.12.2]`)
+- Each version section is immutable once released
+
+### Attribution
+- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/badlogic/pi-mono/issues/123))`
+- **External contributions**: `Added feature X ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))`
+
+## Releasing
+
+1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md
+
+2. **Run release script**:
+ ```bash
+ npm run release:patch # Bug fixes
+ npm run release:minor # New features
+ npm run release:major # Breaking changes
+ ```
+
+The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections.
+
+### Tool Usage
+**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads).
+
+
+Current date and time: Sunday, December 28, 2025 at 08:05:42 PM GMT+1
+Current working directory: /Users/badlogic/workspaces/pi-mono/packages/coding-agent
+
+
+
+
+
+
08:05:42 PM
Hello! I see you haven't given me a specific task yet. Let me start by reading the main README to understand the project structure.
+
08:05:45 PM
Great! I can see this is a monorepo with several packages for building AI agents and managing LLM deployments. The main packages are:
+
+- pi-ai - Unified multi-provider LLM API
+- pi-agent-core - Agent runtime with tool calling and state management
+- pi-coding-agent - Interactive coding agent CLI (the package we're currently in)
+- pi-mom - Slack bot that delegates to the coding agent
+- pi-tui - Terminal UI library
+- pi-web-ui - Web components for AI chat interfaces
+- pi-pods - CLI for managing vLLM deployments on GPU pods
+
+
Which package(s) would you like to work on today?
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index a0d0b107..11a9befd 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -30,7 +30,10 @@ import { exportSessionToHtml } from "./export-html.js";
import type {
HookCommandContext,
HookRunner,
- SessionEventResult,
+ SessionBeforeBranchResult,
+ SessionBeforeCompactResult,
+ SessionBeforeNewResult,
+ SessionBeforeSwitchResult,
TurnEndEvent,
TurnStartEvent,
} from "./hooks/index.js";
@@ -44,7 +47,7 @@ import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
export type AgentSessionEvent =
| AgentEvent
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
- | { type: "auto_compaction_end"; result: CompactionResult | null; aborted: boolean; willRetry: boolean }
+ | { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean }
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
@@ -64,7 +67,7 @@ export interface AgentSessionConfig {
/** File-based slash commands for expansion */
fileCommands?: FileSlashCommand[];
/** Hook runner (created in main.ts with wrapped tools) */
- hookRunner?: HookRunner | null;
+ hookRunner?: HookRunner;
/** Custom tools for session lifecycle events */
customTools?: LoadedCustomTool[];
skillsSettings?: Required;
@@ -90,7 +93,7 @@ export interface ModelCycleResult {
/** Session statistics for /session command */
export interface SessionStats {
- sessionFile: string | null;
+ sessionFile: string | undefined;
sessionId: string;
userMessages: number;
assistantMessages: number;
@@ -138,21 +141,21 @@ export class AgentSession {
private _queuedMessages: string[] = [];
// Compaction state
- private _compactionAbortController: AbortController | null = null;
- private _autoCompactionAbortController: AbortController | null = null;
+ private _compactionAbortController: AbortController | undefined = undefined;
+ private _autoCompactionAbortController: AbortController | undefined = undefined;
// Retry state
- private _retryAbortController: AbortController | null = null;
+ private _retryAbortController: AbortController | undefined = undefined;
private _retryAttempt = 0;
- private _retryPromise: Promise | null = null;
- private _retryResolve: (() => void) | null = null;
+ private _retryPromise: Promise | undefined = undefined;
+ private _retryResolve: (() => void) | undefined = undefined;
// Bash execution state
- private _bashAbortController: AbortController | null = null;
+ private _bashAbortController: AbortController | undefined = undefined;
private _pendingBashMessages: BashExecutionMessage[] = [];
// Hook system
- private _hookRunner: HookRunner | null = null;
+ private _hookRunner: HookRunner | undefined = undefined;
private _turnIndex = 0;
// Custom tools for session lifecycle
@@ -169,10 +172,14 @@ export class AgentSession {
this.settingsManager = config.settingsManager;
this._scopedModels = config.scopedModels ?? [];
this._fileCommands = config.fileCommands ?? [];
- this._hookRunner = config.hookRunner ?? null;
+ this._hookRunner = config.hookRunner;
this._customTools = config.customTools ?? [];
this._skillsSettings = config.skillsSettings;
this._modelRegistry = config.modelRegistry;
+
+ // Always subscribe to agent events for internal handling
+ // (session persistence, hooks, auto-compaction, retry logic)
+ this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
}
/** Model registry for API key resolution and model discovery */
@@ -192,7 +199,7 @@ export class AgentSession {
}
// Track last assistant message for auto-compaction check
- private _lastAssistantMessage: AssistantMessage | null = null;
+ private _lastAssistantMessage: AssistantMessage | undefined = undefined;
/** Internal handler for agent events - shared by subscribe and reconnect */
private _handleAgentEvent = async (event: AgentEvent): Promise => {
@@ -246,7 +253,7 @@ export class AgentSession {
// Check auto-retry and auto-compaction after agent completes
if (event.type === "agent_end" && this._lastAssistantMessage) {
const msg = this._lastAssistantMessage;
- this._lastAssistantMessage = null;
+ this._lastAssistantMessage = undefined;
// Check for retryable errors first (overloaded, rate limit, server errors)
if (this._isRetryableError(msg)) {
@@ -272,8 +279,8 @@ export class AgentSession {
private _resolveRetry(): void {
if (this._retryResolve) {
this._retryResolve();
- this._retryResolve = null;
- this._retryPromise = null;
+ this._retryResolve = undefined;
+ this._retryPromise = undefined;
}
}
@@ -287,7 +294,7 @@ export class AgentSession {
}
/** Find the last assistant message in agent state (including aborted ones) */
- private _findLastAssistantMessage(): AssistantMessage | null {
+ private _findLastAssistantMessage(): AssistantMessage | undefined {
const messages = this.agent.state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
@@ -295,7 +302,7 @@ export class AgentSession {
return msg as AssistantMessage;
}
}
- return null;
+ return undefined;
}
/** Emit hook events based on agent events */
@@ -334,11 +341,6 @@ export class AgentSession {
subscribe(listener: AgentSessionEventListener): () => void {
this._eventListeners.push(listener);
- // Set up agent subscription if not already done
- if (!this._unsubscribeAgent) {
- this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
- }
-
// Return unsubscribe function for this specific listener
return () => {
const index = this._eventListeners.indexOf(listener);
@@ -387,8 +389,8 @@ export class AgentSession {
return this.agent.state;
}
- /** Current model (may be null if not yet selected) */
- get model(): Model | null {
+ /** Current model (may be undefined if not yet selected) */
+ get model(): Model | undefined {
return this.agent.state.model;
}
@@ -404,7 +406,7 @@ export class AgentSession {
/** Whether auto-compaction is currently running */
get isCompacting(): boolean {
- return this._autoCompactionAbortController !== null || this._compactionAbortController !== null;
+ return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined;
}
/** All messages including custom types like BashExecutionMessage */
@@ -417,9 +419,9 @@ export class AgentSession {
return this.agent.getQueueMode();
}
- /** Current session file path, or null if sessions are disabled */
- get sessionFile(): string | null {
- return this.sessionManager.getSessionFile() ?? null;
+ /** Current session file path, or undefined if sessions are disabled */
+ get sessionFile(): string | undefined {
+ return this.sessionManager.getSessionFile();
}
/** Current session ID */
@@ -663,12 +665,11 @@ export class AgentSession {
async reset(): Promise {
const previousSessionFile = this.sessionFile;
- // Emit before_new event (can be cancelled)
- if (this._hookRunner?.hasHandlers("session")) {
+ // Emit session_before_new event (can be cancelled)
+ if (this._hookRunner?.hasHandlers("session_before_new")) {
const result = (await this._hookRunner.emit({
- type: "session",
- reason: "before_new",
- })) as SessionEventResult | undefined;
+ type: "session_before_new",
+ })) as SessionBeforeNewResult | undefined;
if (result?.cancel) {
return false;
@@ -682,11 +683,10 @@ export class AgentSession {
this._queuedMessages = [];
this._reconnectToAgent();
- // Emit session event with reason "new" to hooks
+ // Emit session_new event to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
- type: "session",
- reason: "new",
+ type: "session_new",
});
}
@@ -722,17 +722,17 @@ export class AgentSession {
* Cycle to next/previous model.
* Uses scoped models (from --models flag) if available, otherwise all available models.
* @param direction - "forward" (default) or "backward"
- * @returns The new model info, or null if only one model available
+ * @returns The new model info, or undefined if only one model available
*/
- async cycleModel(direction: "forward" | "backward" = "forward"): Promise {
+ async cycleModel(direction: "forward" | "backward" = "forward"): Promise {
if (this._scopedModels.length > 0) {
return this._cycleScopedModel(direction);
}
return this._cycleAvailableModel(direction);
}
- private async _cycleScopedModel(direction: "forward" | "backward"): Promise {
- if (this._scopedModels.length <= 1) return null;
+ private async _cycleScopedModel(direction: "forward" | "backward"): Promise {
+ if (this._scopedModels.length <= 1) return undefined;
const currentModel = this.model;
let currentIndex = this._scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
@@ -759,9 +759,9 @@ export class AgentSession {
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
}
- private async _cycleAvailableModel(direction: "forward" | "backward"): Promise {
+ private async _cycleAvailableModel(direction: "forward" | "backward"): Promise {
const availableModels = await this._modelRegistry.getAvailable();
- if (availableModels.length <= 1) return null;
+ if (availableModels.length <= 1) return undefined;
const currentModel = this.model;
let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
@@ -816,10 +816,10 @@ export class AgentSession {
/**
* Cycle to next thinking level.
- * @returns New level, or null if model doesn't support thinking
+ * @returns New level, or undefined if model doesn't support thinking
*/
- cycleThinkingLevel(): ThinkingLevel | null {
- if (!this.supportsThinking()) return null;
+ cycleThinkingLevel(): ThinkingLevel | undefined {
+ if (!this.supportsThinking()) return undefined;
const levels = this.getAvailableThinkingLevels();
const currentIndex = levels.indexOf(this.thinkingLevel);
@@ -904,19 +904,18 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
- if (this._hookRunner?.hasHandlers("session")) {
+ if (this._hookRunner?.hasHandlers("session_before_compact")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const result = (await this._hookRunner.emit({
- type: "session",
- reason: "before_compact",
+ type: "session_before_compact",
preparation,
previousCompactions,
customInstructions,
model: this.model,
signal: this._compactionAbortController.signal,
- })) as SessionEventResult | undefined;
+ })) as SessionBeforeCompactResult | undefined;
if (result?.cancel) {
throw new Error("Compaction cancelled");
@@ -971,8 +970,7 @@ export class AgentSession {
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
- type: "session",
- reason: "compact",
+ type: "session_compact",
compactionEntry: savedCompactionEntry,
fromHook,
});
@@ -985,7 +983,7 @@ export class AgentSession {
details,
};
} finally {
- this._compactionAbortController = null;
+ this._compactionAbortController = undefined;
this._reconnectToAgent();
}
}
@@ -1051,13 +1049,13 @@ export class AgentSession {
try {
if (!this.model) {
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
return;
}
const apiKey = await this._modelRegistry.getApiKey(this.model);
if (!apiKey) {
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
return;
}
@@ -1065,29 +1063,28 @@ export class AgentSession {
const preparation = prepareCompaction(entries, settings);
if (!preparation) {
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
return;
}
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
- if (this._hookRunner?.hasHandlers("session")) {
+ if (this._hookRunner?.hasHandlers("session_before_compact")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const hookResult = (await this._hookRunner.emit({
- type: "session",
- reason: "before_compact",
+ type: "session_before_compact",
preparation,
previousCompactions,
customInstructions: undefined,
model: this.model,
signal: this._autoCompactionAbortController.signal,
- })) as SessionEventResult | undefined;
+ })) as SessionBeforeCompactResult | undefined;
if (hookResult?.cancel) {
- this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
return;
}
@@ -1124,7 +1121,7 @@ export class AgentSession {
}
if (this._autoCompactionAbortController.signal.aborted) {
- this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
return;
}
@@ -1140,8 +1137,7 @@ export class AgentSession {
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
- type: "session",
- reason: "compact",
+ type: "session_compact",
compactionEntry: savedCompactionEntry,
fromHook,
});
@@ -1167,7 +1163,7 @@ export class AgentSession {
}, 100);
}
} catch (error) {
- this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
if (reason === "overflow") {
throw new Error(
@@ -1175,7 +1171,7 @@ export class AgentSession {
);
}
} finally {
- this._autoCompactionAbortController = null;
+ this._autoCompactionAbortController = undefined;
}
}
@@ -1267,7 +1263,7 @@ export class AgentSession {
// Aborted during sleep - emit end event so UI can clean up
const attempt = this._retryAttempt;
this._retryAttempt = 0;
- this._retryAbortController = null;
+ this._retryAbortController = undefined;
this._emit({
type: "auto_retry_end",
success: false,
@@ -1277,7 +1273,7 @@ export class AgentSession {
this._resolveRetry();
return false;
}
- this._retryAbortController = null;
+ this._retryAbortController = undefined;
// Retry via continue() - use setTimeout to break out of event handler chain
setTimeout(() => {
@@ -1329,7 +1325,7 @@ export class AgentSession {
/** Whether auto-retry is currently in progress */
get isRetrying(): boolean {
- return this._retryPromise !== null;
+ return this._retryPromise !== undefined;
}
/** Whether auto-retry is enabled */
@@ -1389,7 +1385,7 @@ export class AgentSession {
return result;
} finally {
- this._bashAbortController = null;
+ this._bashAbortController = undefined;
}
}
@@ -1402,7 +1398,7 @@ export class AgentSession {
/** Whether a bash command is currently running */
get isBashRunning(): boolean {
- return this._bashAbortController !== null;
+ return this._bashAbortController !== undefined;
}
/** Whether there are pending bash messages waiting to be flushed */
@@ -1439,15 +1435,14 @@ export class AgentSession {
* @returns true if switch completed, false if cancelled by hook
*/
async switchSession(sessionPath: string): Promise {
- const previousSessionFile = this.sessionFile;
+ const previousSessionFile = this.sessionManager.getSessionFile();
- // Emit before_switch event (can be cancelled)
- if (this._hookRunner?.hasHandlers("session")) {
+ // Emit session_before_switch event (can be cancelled)
+ if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
- type: "session",
- reason: "before_switch",
+ type: "session_before_switch",
targetSessionFile: sessionPath,
- })) as SessionEventResult | undefined;
+ })) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
@@ -1464,11 +1459,10 @@ export class AgentSession {
// Reload messages
const sessionContext = this.sessionManager.buildSessionContext();
- // Emit session event to hooks
+ // Emit session_switch event to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
- type: "session",
- reason: "switch",
+ type: "session_switch",
previousSessionFile,
});
}
@@ -1520,13 +1514,12 @@ export class AgentSession {
let skipConversationRestore = false;
- // Emit before_branch event (can be cancelled)
- if (this._hookRunner?.hasHandlers("session")) {
+ // Emit session_before_branch event (can be cancelled)
+ if (this._hookRunner?.hasHandlers("session_before_branch")) {
const result = (await this._hookRunner.emit({
- type: "session",
- reason: "before_branch",
- targetTurnIndex: entryIndex,
- })) as SessionEventResult | undefined;
+ type: "session_before_branch",
+ entryIndex: entryIndex,
+ })) as SessionBeforeBranchResult | undefined;
if (result?.cancel) {
return { selectedText, cancelled: true };
@@ -1534,27 +1527,20 @@ export class AgentSession {
skipConversationRestore = result?.skipConversationRestore ?? false;
}
- // Create branched session ending before the selected message (returns null in --no-session mode)
- // User will re-enter/edit the selected message
if (!selectedEntry.parentId) {
- throw new Error("Cannot branch from first message");
- }
- const newSessionFile = this.sessionManager.createBranchedSession(selectedEntry.parentId);
-
- // Update session file if we have one (file-based mode)
- if (newSessionFile !== null) {
- this.sessionManager.setSessionFile(newSessionFile);
+ this.sessionManager.newSession();
+ } else {
+ this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
// Reload messages from entries (works for both file and in-memory mode)
const sessionContext = this.sessionManager.buildSessionContext();
- // Emit branch event to hooks (after branch completes)
+ // Emit session_branch event to hooks (after branch completes)
if (this._hookRunner) {
await this._hookRunner.emit({
- type: "session",
- reason: "branch",
- targetTurnIndex: entryIndex,
+ type: "session_branch",
+ previousSessionFile,
});
}
@@ -1664,9 +1650,9 @@ export class AgentSession {
/**
* Get text content of last assistant message.
* Useful for /copy command.
- * @returns Text content, or null if no assistant message exists
+ * @returns Text content, or undefined if no assistant message exists
*/
- getLastAssistantText(): string | null {
+ getLastAssistantText(): string | undefined {
const lastAssistant = this.messages
.slice()
.reverse()
@@ -1678,7 +1664,7 @@ export class AgentSession {
return true;
});
- if (!lastAssistant) return null;
+ if (!lastAssistant) return undefined;
let text = "";
for (const content of (lastAssistant as AssistantMessage).content) {
@@ -1687,7 +1673,7 @@ export class AgentSession {
}
}
- return text.trim() || null;
+ return text.trim() || undefined;
}
// =========================================================================
@@ -1704,7 +1690,7 @@ export class AgentSession {
/**
* Get the hook runner (for setting UI context and error handlers).
*/
- get hookRunner(): HookRunner | null {
+ get hookRunner(): HookRunner | undefined {
return this._hookRunner;
}
@@ -1721,7 +1707,7 @@ export class AgentSession {
*/
private async _emitToolSessionEvent(
reason: ToolSessionEvent["reason"],
- previousSessionFile: string | null,
+ previousSessionFile: string | undefined,
): Promise {
const event: ToolSessionEvent = {
entries: this.sessionManager.getEntries(),
diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts
index afc5e076..0b0c23a5 100644
--- a/packages/coding-agent/src/core/auth-storage.ts
+++ b/packages/coding-agent/src/core/auth-storage.ts
@@ -94,8 +94,8 @@ export class AuthStorage {
/**
* Get credential for a provider.
*/
- get(provider: string): AuthCredential | null {
- return this.data[provider] ?? null;
+ get(provider: string): AuthCredential | undefined {
+ return this.data[provider] ?? undefined;
}
/**
@@ -191,7 +191,7 @@ export class AuthStorage {
* 4. Environment variable
* 5. Fallback resolver (models.json custom providers)
*/
- async getApiKey(provider: string): Promise {
+ async getApiKey(provider: string): Promise {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(provider);
if (runtimeKey) {
@@ -230,6 +230,6 @@ export class AuthStorage {
if (envKey) return envKey;
// Fall back to custom resolver (e.g., models.json custom providers)
- return this.fallbackResolver?.(provider) ?? null;
+ return this.fallbackResolver?.(provider) ?? undefined;
}
}
diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts
index d83f63b5..0a83d2e5 100644
--- a/packages/coding-agent/src/core/bash-executor.ts
+++ b/packages/coding-agent/src/core/bash-executor.ts
@@ -29,8 +29,8 @@ export interface BashExecutorOptions {
export interface BashResult {
/** Combined stdout + stderr output (sanitized, possibly truncated) */
output: string;
- /** Process exit code (null if killed/cancelled) */
- exitCode: number | null;
+ /** Process exit code (undefined if killed/cancelled) */
+ exitCode: number | undefined;
/** Whether the command was cancelled via signal */
cancelled: boolean;
/** Whether the output was truncated */
@@ -88,7 +88,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
child.kill();
resolve({
output: "",
- exitCode: null,
+ exitCode: undefined,
cancelled: true,
truncated: false,
});
@@ -154,7 +154,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
resolve({
output: truncationResult.truncated ? truncationResult.content : fullOutput,
- exitCode: code,
+ exitCode: cancelled ? undefined : code,
cancelled,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,
diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts
index ac750a9e..35d933a3 100644
--- a/packages/coding-agent/src/core/compaction.ts
+++ b/packages/coding-agent/src/core/compaction.ts
@@ -13,9 +13,9 @@ import type { CompactionEntry, SessionEntry } from "./session-manager.js";
/**
* Extract AgentMessage from an entry if it produces one.
- * Returns null for entries that don't contribute to LLM context.
+ * Returns undefined for entries that don't contribute to LLM context.
*/
-function getMessageFromEntry(entry: SessionEntry): AgentMessage | null {
+function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message;
}
@@ -25,7 +25,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | null {
if (entry.type === "branch_summary") {
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
}
- return null;
+ return undefined;
}
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
@@ -69,20 +69,20 @@ export function calculateContextTokens(usage: Usage): number {
* Get usage from an assistant message if available.
* Skips aborted and error messages as they don't have valid usage data.
*/
-function getAssistantUsage(msg: AgentMessage): Usage | null {
+function getAssistantUsage(msg: AgentMessage): Usage | undefined {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
return assistantMsg.usage;
}
}
- return null;
+ return undefined;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
-export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
+export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
@@ -90,7 +90,7 @@ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
if (usage) return usage;
}
}
- return null;
+ return undefined;
}
/**
@@ -398,9 +398,12 @@ export interface CompactionPreparation {
boundaryStart: number;
}
-export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null {
+export function prepareCompaction(
+ entries: SessionEntry[],
+ settings: CompactionSettings,
+): CompactionPreparation | undefined {
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
- return null;
+ return undefined;
}
let prevCompactionIndex = -1;
@@ -421,7 +424,7 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS
// Get UUID of first kept entry
const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
- return null; // Session needs migration
+ return undefined; // Session needs migration
}
const firstKeptEntryId = firstKeptEntry.id;
diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts
index f22cb6c9..f4480611 100644
--- a/packages/coding-agent/src/core/custom-tools/loader.ts
+++ b/packages/coding-agent/src/core/custom-tools/loader.ts
@@ -86,9 +86,9 @@ function resolveToolPath(toolPath: string, cwd: string): string {
*/
function createNoOpUIContext(): HookUIContext {
return {
- select: async () => null,
+ select: async () => undefined,
confirm: async () => false,
- input: async () => null,
+ input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
};
diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts
index fa4446a7..a9a69b99 100644
--- a/packages/coding-agent/src/core/custom-tools/types.ts
+++ b/packages/coding-agent/src/core/custom-tools/types.ts
@@ -38,10 +38,10 @@ export interface ToolAPI {
export interface SessionEvent {
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
- /** Current session file path, or null in --no-session mode */
- sessionFile: string | null;
- /** Previous session file path, or null for "start" and "new" */
- previousSessionFile: string | null;
+ /** Current session file path, or undefined in --no-session mode */
+ sessionFile: string | undefined;
+ /** Previous session file path, or undefined for "start" and "new" */
+ previousSessionFile: string | undefined;
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "new";
}
diff --git a/packages/coding-agent/src/core/export-html.ts b/packages/coding-agent/src/core/export-html.ts
index f553e140..c6f3139f 100644
--- a/packages/coding-agent/src/core/export-html.ts
+++ b/packages/coding-agent/src/core/export-html.ts
@@ -121,7 +121,7 @@ function resolveColorValue(
}
/** Load theme JSON from built-in or custom themes directory. */
-function loadThemeJson(name: string): ThemeJson | null {
+function loadThemeJson(name: string): ThemeJson | undefined {
// Try built-in themes first
const themesDir = getThemesDir();
const builtinPath = path.join(themesDir, `${name}.json`);
@@ -129,7 +129,7 @@ function loadThemeJson(name: string): ThemeJson | null {
try {
return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson;
} catch {
- return null;
+ return undefined;
}
}
@@ -140,11 +140,11 @@ function loadThemeJson(name: string): ThemeJson | null {
try {
return JSON.parse(readFileSync(customPath, "utf-8")) as ThemeJson;
} catch {
- return null;
+ return undefined;
}
}
- return null;
+ return undefined;
}
/** Build complete theme colors object, resolving theme JSON values against defaults. */
@@ -831,7 +831,9 @@ function formatMessage(
switch (message.role) {
case "bashExecution": {
- const isError = message.cancelled || (message.exitCode !== 0 && message.exitCode !== null);
+ const isError =
+ message.cancelled ||
+ (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined);
html += ``;
html += timestampHtml;
@@ -844,7 +846,7 @@ function formatMessage(
if (message.cancelled) {
html += `
(cancelled)
`;
- } else if (message.exitCode !== 0 && message.exitCode !== null) {
+ } else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) {
html += `
(exit ${message.exitCode})
`;
}
@@ -1020,7 +1022,7 @@ function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeCo
const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
const contextWindow = data.contextWindow || 0;
- const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null;
+ const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : undefined;
let messagesHtml = "";
for (const event of data.sessionEvents) {
diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts
index 395d15e0..fc49f2ae 100644
--- a/packages/coding-agent/src/core/hooks/index.ts
+++ b/packages/coding-agent/src/core/hooks/index.ts
@@ -9,49 +9,4 @@ export {
} from "./loader.js";
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
-export type {
- AgentEndEvent,
- AgentStartEvent,
- BeforeAgentStartEvent,
- BeforeAgentStartEventResult,
- BashToolResultEvent,
- ContextEvent,
- ContextEventResult,
- CustomToolResultEvent,
- EditToolResultEvent,
- ExecOptions,
- ExecResult,
- FindToolResultEvent,
- GrepToolResultEvent,
- HookAPI,
- HookCommandContext,
- HookError,
- HookEvent,
- HookEventContext,
- HookFactory,
- HookMessageRenderer,
- HookMessageRenderOptions,
- HookUIContext,
- LsToolResultEvent,
- ReadonlySessionManager,
- ReadToolResultEvent,
- RegisteredCommand,
- SessionEvent,
- SessionEventResult,
- ToolCallEvent,
- ToolCallEventResult,
- ToolResultEvent,
- ToolResultEventResult,
- TurnEndEvent,
- TurnStartEvent,
- WriteToolResultEvent,
-} from "./types.js";
-export {
- isBashToolResult,
- isEditToolResult,
- isFindToolResult,
- isGrepToolResult,
- isLsToolResult,
- isReadToolResult,
- isWriteToolResult,
-} from "./types.js";
+export type * from "./types.js";
diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts
index 76c17f51..eee9090a 100644
--- a/packages/coding-agent/src/core/hooks/runner.ts
+++ b/packages/coding-agent/src/core/hooks/runner.ts
@@ -17,8 +17,7 @@ import type {
HookMessageRenderer,
HookUIContext,
RegisteredCommand,
- SessionEvent,
- SessionEventResult,
+ SessionBeforeCompactResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
@@ -53,9 +52,9 @@ function createTimeout(ms: number): { promise: Promise
; clear: () => void
/** No-op UI context used when no UI is available */
const noOpUIContext: HookUIContext = {
- select: async () => null,
+ select: async () => undefined,
confirm: async () => false,
- input: async () => null,
+ input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
};
@@ -228,12 +227,26 @@ export class HookRunner {
}
/**
- * Emit an event to all hooks.
- * Returns the result from session/tool_result events (if any handler returns one).
+ * Check if event type is a session "before_*" event that can be cancelled.
*/
- async emit(event: HookEvent): Promise {
+ private isSessionBeforeEvent(
+ type: string,
+ ): type is "session_before_switch" | "session_before_new" | "session_before_branch" | "session_before_compact" {
+ return (
+ type === "session_before_switch" ||
+ type === "session_before_new" ||
+ type === "session_before_branch" ||
+ type === "session_before_compact"
+ );
+ }
+
+ /**
+ * Emit an event to all hooks.
+ * Returns the result from session before_* / tool_result events (if any handler returns one).
+ */
+ async emit(event: HookEvent): Promise {
const ctx = this.createContext();
- let result: SessionEventResult | ToolResultEventResult | undefined;
+ let result: SessionBeforeCompactResult | ToolResultEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type);
@@ -241,11 +254,10 @@ export class HookRunner {
for (const handler of handlers) {
try {
- // No timeout for before_compact events (like tool_call, they may take a while)
- const isBeforeCompact = event.type === "session" && (event as SessionEvent).reason === "before_compact";
+ // No timeout for session_before_compact events (like tool_call, they may take a while)
let handlerResult: unknown;
- if (isBeforeCompact) {
+ if (event.type === "session_before_compact") {
handlerResult = await handler(event, ctx);
} else {
const timeout = createTimeout(this.timeout);
@@ -253,9 +265,9 @@ export class HookRunner {
timeout.clear();
}
- // For session events, capture the result (for before_* cancellation)
- if (event.type === "session" && handlerResult) {
- result = handlerResult as SessionEventResult;
+ // For session before_* events, capture the result (for cancellation)
+ if (this.isSessionBeforeEvent(event.type) && handlerResult) {
+ result = handlerResult as SessionBeforeCompactResult;
// If cancelled, stop processing further hooks
if (result.cancel) {
return result;
diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts
index e97e20e0..950464d6 100644
--- a/packages/coding-agent/src/core/hooks/types.ts
+++ b/packages/coding-agent/src/core/hooks/types.ts
@@ -13,13 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
-import type {
- CompactionEntry,
- SessionEntry,
- SessionHeader,
- SessionManager,
- SessionTreeNode,
-} from "../session-manager.js";
+import type { CompactionEntry, SessionManager } from "../session-manager.js";
/**
* Read-only view of SessionManager for hooks.
@@ -64,7 +58,7 @@ export interface HookUIContext {
* @param options - Array of string options
* @returns Selected option string, or null if cancelled
*/
- select(title: string, options: string[]): Promise;
+ select(title: string, options: string[]): Promise;
/**
* Show a confirmation dialog.
@@ -74,9 +68,9 @@ export interface HookUIContext {
/**
* Show a text input dialog.
- * @returns User input, or null if cancelled
+ * @returns User input, or undefined if cancelled
*/
- input(title: string, placeholder?: string): Promise;
+ input(title: string, placeholder?: string): Promise;
/**
* Show a notification to the user.
@@ -110,69 +104,91 @@ export interface HookEventContext {
}
// ============================================================================
-// Events
+// Session Events
// ============================================================================
-/**
- * Base fields shared by all session events.
- */
-interface SessionEventBase {
- type: "session";
+/** Fired on initial session load */
+export interface SessionStartEvent {
+ type: "session_start";
}
-/**
- * Event data for session events.
- * Discriminated union based on reason.
- *
- * Lifecycle:
- * - start: Initial session load
- * - before_switch / switch: Session switch (e.g., /resume command)
- * - before_new / new: New session (e.g., /new command)
- * - before_branch / branch: Session branch (e.g., /branch command)
- * - before_compact / compact: Before/after context compaction
- * - shutdown: Process exit (SIGINT/SIGTERM)
- *
- * "before_*" events fire before the action and can be cancelled via SessionEventResult.
- * Other events fire after the action completes.
- */
+/** Fired before switching to another session (can be cancelled) */
+export interface SessionBeforeSwitchEvent {
+ type: "session_before_switch";
+ /** Session file we're switching to */
+ targetSessionFile: string;
+}
+
+/** Fired after switching to another session */
+export interface SessionSwitchEvent {
+ type: "session_switch";
+ /** Session file we came from */
+ previousSessionFile: string | undefined;
+}
+
+/** Fired before creating a new session (can be cancelled) */
+export interface SessionBeforeNewEvent {
+ type: "session_before_new";
+}
+
+/** Fired after creating a new session */
+export interface SessionNewEvent {
+ type: "session_new";
+}
+
+/** Fired before branching a session (can be cancelled) */
+export interface SessionBeforeBranchEvent {
+ type: "session_before_branch";
+ /** Index of the entry in the session (SessionManager.getEntries()) to branch from */
+ entryIndex: number;
+}
+
+/** Fired after branching a session */
+export interface SessionBranchEvent {
+ type: "session_branch";
+ previousSessionFile: string | undefined;
+}
+
+/** Fired before context compaction (can be cancelled or customized) */
+export interface SessionBeforeCompactEvent {
+ type: "session_before_compact";
+ /** Compaction preparation with cut point, messages to summarize/keep, etc. */
+ preparation: CompactionPreparation;
+ /** Previous compaction entries, newest first. Use for iterative summarization. */
+ previousCompactions: CompactionEntry[];
+ /** Optional user-provided instructions for the summary */
+ customInstructions?: string;
+ /** Current model */
+ model: Model;
+ /** Abort signal - hooks should pass this to LLM calls and check it periodically */
+ signal: AbortSignal;
+}
+
+/** Fired after context compaction */
+export interface SessionCompactEvent {
+ type: "session_compact";
+ compactionEntry: CompactionEntry;
+ /** Whether the compaction entry was provided by a hook */
+ fromHook: boolean;
+}
+
+/** Fired on process exit (SIGINT/SIGTERM) */
+export interface SessionShutdownEvent {
+ type: "session_shutdown";
+}
+
+/** Union of all session event types */
export type SessionEvent =
- | (SessionEventBase & {
- reason: "start" | "new" | "before_new" | "shutdown";
- })
- | (SessionEventBase & {
- reason: "before_switch";
- /** Session file we're switching to */
- targetSessionFile: string;
- })
- | (SessionEventBase & {
- reason: "switch";
- /** Session file we came from */
- previousSessionFile: string | null;
- })
- | (SessionEventBase & {
- reason: "branch" | "before_branch";
- /** Index of the turn to branch from */
- targetTurnIndex: number;
- })
- | (SessionEventBase & {
- reason: "before_compact";
- /** Compaction preparation with cut point, messages to summarize/keep, etc. */
- preparation: CompactionPreparation;
- /** Previous compaction entries, newest first. Use for iterative summarization. */
- previousCompactions: CompactionEntry[];
- /** Optional user-provided instructions for the summary */
- customInstructions?: string;
- /** Current model */
- model: Model;
- /** Abort signal - hooks should pass this to LLM calls and check it periodically */
- signal: AbortSignal;
- })
- | (SessionEventBase & {
- reason: "compact";
- compactionEntry: CompactionEntry;
- /** Whether the compaction entry was provided by a hook */
- fromHook: boolean;
- });
+ | SessionStartEvent
+ | SessionBeforeSwitchEvent
+ | SessionSwitchEvent
+ | SessionBeforeNewEvent
+ | SessionNewEvent
+ | SessionBeforeBranchEvent
+ | SessionBranchEvent
+ | SessionBeforeCompactEvent
+ | SessionCompactEvent
+ | SessionShutdownEvent;
/**
* Event data for context event.
@@ -408,16 +424,45 @@ export interface BeforeAgentStartEventResult {
message?: Pick;
}
-/**
- * Return type for session event handlers.
- * Allows hooks to cancel "before_*" actions.
- */
-export interface SessionEventResult {
- /** If true, cancel the pending action (switch, clear, or branch) */
+/** Return type for session_before_switch handlers */
+export interface SessionBeforeSwitchResult {
+ /** If true, cancel the switch */
cancel?: boolean;
- /** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */
+}
+
+/** Return type for session_before_new handlers */
+export interface SessionBeforeNewResult {
+ /** If true, cancel the new session */
+ cancel?: boolean;
+}
+
+/** Return type for session_before_branch handlers */
+export interface SessionBeforeBranchResult {
+ /**
+ * If true, abort the branch entirely. No new session file is created,
+ * conversation stays unchanged.
+ */
+ cancel?: boolean;
+ /**
+ * If true, the branch proceeds (new session file created, session state updated)
+ * but the in-memory conversation is NOT rewound to the branch point.
+ *
+ * Use case: git-checkpoint hook that restores code state separately.
+ * The hook handles state restoration itself, so it doesn't want the
+ * agent's conversation to be rewound (which would lose recent context).
+ *
+ * - `cancel: true` → nothing happens, user stays in current session
+ * - `skipConversationRestore: true` → branch happens, but messages stay as-is
+ * - neither → branch happens AND messages rewind to branch point (default)
+ */
skipConversationRestore?: boolean;
- /** Custom compaction result (for before_compact event) - SessionManager adds id/parentId */
+}
+
+/** Return type for session_before_compact handlers */
+export interface SessionBeforeCompactResult {
+ /** If true, cancel the compaction */
+ cancel?: boolean;
+ /** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
@@ -427,8 +472,10 @@ export interface SessionEventResult {
/**
* Handler function type for each event.
+ * Handlers can return R, undefined, or void (bare return statements).
*/
-export type HookHandler = (event: E, ctx: HookEventContext) => Promise;
+// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
+export type HookHandler = (event: E, ctx: HookEventContext) => Promise | R | void;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */
@@ -443,7 +490,7 @@ export type HookMessageRenderer = (
message: HookMessage,
options: HookMessageRenderOptions,
theme: Theme,
-) => Component | null;
+) => Component | undefined;
/**
* Context passed to hook command handlers.
@@ -478,21 +525,30 @@ export interface RegisteredCommand {
* Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages.
*/
export interface HookAPI {
- // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
- on(event: "session", handler: HookHandler): void;
- // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
- on(event: "context", handler: HookHandler): void;
+ // Session events
+ on(event: "session_start", handler: HookHandler): void;
+ on(event: "session_before_switch", handler: HookHandler): void;
+ on(event: "session_switch", handler: HookHandler): void;
+ on(event: "session_before_new", handler: HookHandler): void;
+ on(event: "session_new", handler: HookHandler): void;
+ on(event: "session_before_branch", handler: HookHandler): void;
+ on(event: "session_branch", handler: HookHandler): void;
on(
- event: "before_agent_start",
- // biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
- handler: HookHandler,
+ event: "session_before_compact",
+ handler: HookHandler,
): void;
+ on(event: "session_compact", handler: HookHandler): void;
+ on(event: "session_shutdown", handler: HookHandler): void;
+
+ // Context and agent events
+ on(event: "context", handler: HookHandler): void;
+ on(event: "before_agent_start", handler: HookHandler): void;
on(event: "agent_start", handler: HookHandler): void;
on(event: "agent_end", handler: HookHandler): void;
on(event: "turn_start", handler: HookHandler): void;
on(event: "turn_end", handler: HookHandler): void;
- on(event: "tool_call", handler: HookHandler): void;
- on(event: "tool_result", handler: HookHandler): void;
+ on(event: "tool_call", handler: HookHandler): void;
+ on(event: "tool_result", handler: HookHandler): void;
/**
* Send a custom message to the session. Creates a CustomMessageEntry that
@@ -545,7 +601,7 @@ export interface HookAPI {
/**
* Register a custom renderer for CustomMessageEntry with a specific customType.
* The renderer is called when rendering the entry in the TUI.
- * Return null to use the default renderer.
+ * Return nothing to use the default renderer.
*/
registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void;
diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts
index f9d33bbf..2726903d 100644
--- a/packages/coding-agent/src/core/messages.ts
+++ b/packages/coding-agent/src/core/messages.ts
@@ -30,7 +30,7 @@ export interface BashExecutionMessage {
role: "bashExecution";
command: string;
output: string;
- exitCode: number | null;
+ exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
@@ -86,7 +86,7 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
}
if (msg.cancelled) {
text += "\n\n(command cancelled)";
- } else if (msg.exitCode !== null && msg.exitCode !== 0) {
+ } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
text += `\n\nCommand exited with code ${msg.exitCode}`;
}
if (msg.truncated && msg.fullOutputPath) {
@@ -145,7 +145,7 @@ export function createHookMessage(
*/
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
- .map((m): Message | null => {
+ .map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
return {
@@ -182,8 +182,8 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
default:
// biome-ignore lint/correctness/noSwitchDeclarations: fine
const _exhaustiveCheck: never = m;
- return null;
+ return undefined;
}
})
- .filter((m) => m !== null);
+ .filter((m) => m !== undefined);
}
diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts
index 91aa0f8c..94a839f8 100644
--- a/packages/coding-agent/src/core/model-registry.ts
+++ b/packages/coding-agent/src/core/model-registry.ts
@@ -90,11 +90,11 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
export class ModelRegistry {
private models: Model[] = [];
private customProviderApiKeys: Map = new Map();
- private loadError: string | null = null;
+ private loadError: string | undefined = undefined;
constructor(
readonly authStorage: AuthStorage,
- private modelsJsonPath: string | null = null,
+ private modelsJsonPath: string | undefined = undefined,
) {
// Set up fallback resolver for custom provider API keys
this.authStorage.setFallbackResolver((provider) => {
@@ -114,14 +114,14 @@ export class ModelRegistry {
*/
refresh(): void {
this.customProviderApiKeys.clear();
- this.loadError = null;
+ this.loadError = undefined;
this.loadModels();
}
/**
- * Get any error from loading models.json (null if no error).
+ * Get any error from loading models.json (undefined if no error).
*/
- getError(): string | null {
+ getError(): string | undefined {
return this.loadError;
}
@@ -160,9 +160,9 @@ export class ModelRegistry {
}
}
- private loadCustomModels(modelsJsonPath: string): { models: Model[]; error: string | null } {
+ private loadCustomModels(modelsJsonPath: string): { models: Model[]; error: string | undefined } {
if (!existsSync(modelsJsonPath)) {
- return { models: [], error: null };
+ return { models: [], error: undefined };
}
try {
@@ -186,7 +186,7 @@ export class ModelRegistry {
this.validateConfig(config);
// Parse models
- return { models: this.parseModels(config), error: null };
+ return { models: this.parseModels(config), error: undefined };
} catch (error) {
if (error instanceof SyntaxError) {
return {
@@ -294,14 +294,14 @@ export class ModelRegistry {
/**
* Find a model by provider and ID.
*/
- find(provider: string, modelId: string): Model | null {
- return this.models.find((m) => m.provider === provider && m.id === modelId) ?? null;
+ find(provider: string, modelId: string): Model | undefined {
+ return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined;
}
/**
* Get API key for a model.
*/
- async getApiKey(model: Model): Promise {
+ async getApiKey(model: Model): Promise {
return this.authStorage.getApiKey(model.provider);
}
diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts
index 1734c015..981f11f2 100644
--- a/packages/coding-agent/src/core/model-resolver.ts
+++ b/packages/coding-agent/src/core/model-resolver.ts
@@ -44,9 +44,9 @@ function isAlias(id: string): boolean {
/**
* Try to match a pattern to a model from the available models list.
- * Returns the matched model or null if no match found.
+ * Returns the matched model or undefined if no match found.
*/
-function tryMatchModel(modelPattern: string, availableModels: Model[]): Model | null {
+function tryMatchModel(modelPattern: string, availableModels: Model[]): Model | undefined {
// Check for provider/modelId format (provider is everything before the first /)
const slashIndex = modelPattern.indexOf("/");
if (slashIndex !== -1) {
@@ -75,7 +75,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model[]): Mod
);
if (matches.length === 0) {
- return null;
+ return undefined;
}
// Separate into aliases and dated versions
@@ -94,9 +94,9 @@ function tryMatchModel(modelPattern: string, availableModels: Model[]): Mod
}
export interface ParsedModelResult {
- model: Model | null;
+ model: Model | undefined;
thinkingLevel: ThinkingLevel;
- warning: string | null;
+ warning: string | undefined;
}
/**
@@ -116,14 +116,14 @@ export function parseModelPattern(pattern: string, availableModels: Model[]
// Try exact match first
const exactMatch = tryMatchModel(pattern, availableModels);
if (exactMatch) {
- return { model: exactMatch, thinkingLevel: "off", warning: null };
+ return { model: exactMatch, thinkingLevel: "off", warning: undefined };
}
// No match - try splitting on last colon if present
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
// No colons, pattern simply doesn't match any model
- return { model: null, thinkingLevel: "off", warning: null };
+ return { model: undefined, thinkingLevel: "off", warning: undefined };
}
const prefix = pattern.substring(0, lastColonIndex);
@@ -193,9 +193,9 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
}
export interface InitialModelResult {
- model: Model | null;
+ model: Model | undefined;
thinkingLevel: ThinkingLevel;
- fallbackMessage: string | null;
+ fallbackMessage: string | undefined;
}
/**
@@ -227,7 +227,7 @@ export async function findInitialModel(options: {
modelRegistry,
} = options;
- let model: Model | null = null;
+ let model: Model | undefined;
let thinkingLevel: ThinkingLevel = "off";
// 1. CLI args take priority
@@ -237,7 +237,7 @@ export async function findInitialModel(options: {
console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
process.exit(1);
}
- return { model: found, thinkingLevel: "off", fallbackMessage: null };
+ return { model: found, thinkingLevel: "off", fallbackMessage: undefined };
}
// 2. Use first model from scoped models (skip if continuing/resuming)
@@ -245,7 +245,7 @@ export async function findInitialModel(options: {
return {
model: scopedModels[0].model,
thinkingLevel: scopedModels[0].thinkingLevel,
- fallbackMessage: null,
+ fallbackMessage: undefined,
};
}
@@ -257,7 +257,7 @@ export async function findInitialModel(options: {
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
- return { model, thinkingLevel, fallbackMessage: null };
+ return { model, thinkingLevel, fallbackMessage: undefined };
}
}
@@ -270,16 +270,16 @@ export async function findInitialModel(options: {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
- return { model: match, thinkingLevel: "off", fallbackMessage: null };
+ return { model: match, thinkingLevel: "off", fallbackMessage: undefined };
}
}
// If no default found, use first available
- return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: null };
+ return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: undefined };
}
// 5. No model found
- return { model: null, thinkingLevel: "off", fallbackMessage: null };
+ return { model: undefined, thinkingLevel: "off", fallbackMessage: undefined };
}
/**
@@ -288,10 +288,10 @@ export async function findInitialModel(options: {
export async function restoreModelFromSession(
savedProvider: string,
savedModelId: string,
- currentModel: Model | null,
+ currentModel: Model | undefined,
shouldPrintMessages: boolean,
modelRegistry: ModelRegistry,
-): Promise<{ model: Model | null; fallbackMessage: string | null }> {
+): Promise<{ model: Model | undefined; fallbackMessage: string | undefined }> {
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
// Check if restored model exists and has a valid API key
@@ -301,7 +301,7 @@ export async function restoreModelFromSession(
if (shouldPrintMessages) {
console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));
}
- return { model: restoredModel, fallbackMessage: null };
+ return { model: restoredModel, fallbackMessage: undefined };
}
// Model not found or no API key - fall back
@@ -327,7 +327,7 @@ export async function restoreModelFromSession(
if (availableModels.length > 0) {
// Try to find a default model from known providers
- let fallbackModel: Model | null = null;
+ let fallbackModel: Model | undefined;
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
@@ -353,5 +353,5 @@ export async function restoreModelFromSession(
}
// No models available
- return { model: null, fallbackMessage: null };
+ return { model: undefined, fallbackMessage: undefined };
}
diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts
index 80de1ca6..089691d3 100644
--- a/packages/coding-agent/src/core/sdk.ts
+++ b/packages/coding-agent/src/core/sdk.ts
@@ -530,7 +530,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
customToolsResult = result;
}
- let hookRunner: HookRunner | null = null;
+ let hookRunner: HookRunner | undefined;
if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts
index 7035c1e0..9a061eb6 100644
--- a/packages/coding-agent/src/core/session-manager.ts
+++ b/packages/coding-agent/src/core/session-manager.ts
@@ -450,7 +450,7 @@ export class SessionManager {
private labelsById: Map = new Map();
private leafId: string = "";
- private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) {
+ private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
this.cwd = cwd;
this.sessionDir = sessionDir;
this.persist = persist;
@@ -484,7 +484,7 @@ export class SessionManager {
}
}
- newSession(): void {
+ newSession(): string | undefined {
this.sessionId = randomUUID();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
@@ -503,6 +503,7 @@ export class SessionManager {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
}
+ return this.sessionFile;
}
private _buildIndex(): void {
@@ -841,9 +842,9 @@ export class SessionManager {
/**
* Create a new session file containing only the path from root to the specified leaf.
* Useful for extracting a single conversation path from a branched session.
- * Returns the new session file path, or null if not persisting.
+ * Returns the new session file path, or undefined if not persisting.
*/
- createBranchedSession(leafId: string): string | null {
+ createBranchedSession(leafId: string): string | undefined {
const path = this.getPath(leafId);
if (path.length === 0) {
throw new Error(`Entry ${leafId} not found`);
@@ -883,6 +884,7 @@ export class SessionManager {
// Write fresh label entries at the end
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
let parentId = lastEntryId;
+ const labelEntries: LabelEntry[] = [];
for (const { targetId, label } of labelsToWrite) {
const labelEntry: LabelEntry = {
type: "label",
@@ -894,8 +896,12 @@ export class SessionManager {
};
appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
pathEntryIds.add(labelEntry.id);
+ labelEntries.push(labelEntry);
parentId = labelEntry.id;
}
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
+ this.sessionId = newSessionId;
+ this._buildIndex();
return newSessionFile;
}
@@ -917,7 +923,7 @@ export class SessionManager {
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.sessionId = newSessionId;
this._buildIndex();
- return null;
+ return undefined;
}
/**
@@ -927,7 +933,7 @@ export class SessionManager {
*/
static create(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
- return new SessionManager(cwd, dir, null, true);
+ return new SessionManager(cwd, dir, undefined, true);
}
/**
@@ -956,12 +962,12 @@ export class SessionManager {
if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true);
}
- return new SessionManager(cwd, dir, null, true);
+ return new SessionManager(cwd, dir, undefined, true);
}
/** Create an in-memory session (no file persistence) */
static inMemory(cwd: string = process.cwd()): SessionManager {
- return new SessionManager(cwd, "", null, false);
+ return new SessionManager(cwd, "", undefined, false);
}
/**
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index b363b1ce..1e563429 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -38,33 +38,7 @@ export type {
ToolUIContext,
} from "./core/custom-tools/index.js";
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
-export type {
- AgentEndEvent,
- AgentStartEvent,
- BashToolResultEvent,
- BeforeAgentStartEvent,
- BeforeAgentStartEventResult,
- CustomToolResultEvent,
- EditToolResultEvent,
- FindToolResultEvent,
- GrepToolResultEvent,
- HookAPI,
- HookEvent,
- HookEventContext,
- HookFactory,
- HookUIContext,
- LsToolResultEvent,
- ReadToolResultEvent,
- SessionEvent,
- SessionEventResult,
- ToolCallEvent,
- ToolCallEventResult,
- ToolResultEvent,
- ToolResultEventResult,
- TurnEndEvent,
- TurnStartEvent,
- WriteToolResultEvent,
-} from "./core/hooks/index.js";
+export type * from "./core/hooks/index.js";
// Hook system types and type guards
export {
isBashToolResult,
diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts
index 30648990..9b67d748 100644
--- a/packages/coding-agent/src/main.ts
+++ b/packages/coding-agent/src/main.ts
@@ -33,10 +33,10 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
import { ensureTool } from "./utils/tools-manager.js";
-async function checkForNewVersion(currentVersion: string): Promise {
+async function checkForNewVersion(currentVersion: string): Promise {
try {
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi -coding-agent/latest");
- if (!response.ok) return null;
+ if (!response.ok) return undefined;
const data = (await response.json()) as { version?: string };
const latestVersion = data.version;
@@ -45,26 +45,26 @@ async function checkForNewVersion(currentVersion: string): Promise,
+ versionCheckPromise: Promise,
initialMessages: string[],
customTools: LoadedCustomTool[],
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
initialMessage?: string,
initialImages?: ImageContent[],
- fdPath: string | null = null,
+ fdPath: string | undefined = undefined,
): Promise {
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
@@ -143,9 +143,9 @@ async function prepareInitialMessage(parsed: Args): Promise<{
};
}
-function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
+function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined {
if (parsed.continue || parsed.resume) {
- return null;
+ return undefined;
}
const lastVersion = settingsManager.getLastChangelogVersion();
@@ -165,10 +165,10 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager):
}
}
- return null;
+ return undefined;
}
-function createSessionManager(parsed: Args, cwd: string): SessionManager | null {
+function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined {
if (parsed.noSession) {
return SessionManager.inMemory();
}
@@ -183,8 +183,8 @@ function createSessionManager(parsed: Args, cwd: string): SessionManager | null
if (parsed.sessionDir) {
return SessionManager.create(cwd, parsed.sessionDir);
}
- // Default case (new session) returns null, SDK will create one
- return null;
+ // Default case (new session) returns undefined, SDK will create one
+ return undefined;
}
/** Discover SYSTEM.md file if no CLI system prompt was provided */
@@ -207,7 +207,7 @@ function discoverSystemPromptFile(): string | undefined {
function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
- sessionManager: SessionManager | null,
+ sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
@@ -408,7 +408,7 @@ export async function main(args: string[]) {
if (mode === "rpc") {
await runRpcMode(session);
} else if (isInteractive) {
- const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
+ const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
if (scopedModels.length > 0) {
diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
index 64a32af7..2e914fa9 100644
--- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
+++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
@@ -21,7 +21,7 @@ export class BashExecutionComponent extends Container {
private command: string;
private outputLines: string[] = [];
private status: "running" | "complete" | "cancelled" | "error" = "running";
- private exitCode: number | null = null;
+ private exitCode: number | undefined = undefined;
private loader: Loader;
private truncationResult?: TruncationResult;
private fullOutputPath?: string;
@@ -90,13 +90,17 @@ export class BashExecutionComponent extends Container {
}
setComplete(
- exitCode: number | null,
+ exitCode: number | undefined,
cancelled: boolean,
truncationResult?: TruncationResult,
fullOutputPath?: string,
): void {
this.exitCode = exitCode;
- this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
+ this.status = cancelled
+ ? "cancelled"
+ : exitCode !== 0 && exitCode !== undefined && exitCode !== null
+ ? "error"
+ : "complete";
this.truncationResult = truncationResult;
this.fullOutputPath = fullOutputPath;
diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts
index 40236899..bbf77f2a 100644
--- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts
@@ -36,18 +36,18 @@ export class ModelSelectorComponent extends Container {
private allModels: ModelItem[] = [];
private filteredModels: ModelItem[] = [];
private selectedIndex: number = 0;
- private currentModel: Model | null;
+ private currentModel?: Model;
private settingsManager: SettingsManager;
private modelRegistry: ModelRegistry;
private onSelectCallback: (model: Model) => void;
private onCancelCallback: () => void;
- private errorMessage: string | null = null;
+ private errorMessage?: string;
private tui: TUI;
private scopedModels: ReadonlyArray;
constructor(
tui: TUI,
- currentModel: Model | null,
+ currentModel: Model | undefined,
settingsManager: SettingsManager,
modelRegistry: ModelRegistry,
scopedModels: ReadonlyArray,
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index d1e12f6c..4d804832 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -67,14 +67,14 @@ export class InteractiveMode {
private version: string;
private isInitialized = false;
private onInputCallback?: (text: string) => void;
- private loadingAnimation: Loader | null = null;
+ private loadingAnimation: Loader | undefined = undefined;
private lastSigintTime = 0;
private lastEscapeTime = 0;
- private changelogMarkdown: string | null = null;
+ private changelogMarkdown: string | undefined = undefined;
// Streaming message tracking
- private streamingComponent: AssistantMessageComponent | null = null;
+ private streamingComponent: AssistantMessageComponent | undefined = undefined;
// Tool execution tracking: toolCallId -> component
private pendingTools = new Map();
@@ -92,22 +92,22 @@ export class InteractiveMode {
private isBashMode = false;
// Track current bash execution component
- private bashComponent: BashExecutionComponent | null = null;
+ private bashComponent: BashExecutionComponent | undefined = undefined;
// Track pending bash components (shown in pending area, moved to chat on submit)
private pendingBashComponents: BashExecutionComponent[] = [];
// Auto-compaction state
- private autoCompactionLoader: Loader | null = null;
+ private autoCompactionLoader: Loader | undefined = undefined;
private autoCompactionEscapeHandler?: () => void;
// Auto-retry state
- private retryLoader: Loader | null = null;
+ private retryLoader: Loader | undefined = undefined;
private retryEscapeHandler?: () => void;
// Hook UI state
- private hookSelector: HookSelectorComponent | null = null;
- private hookInput: HookInputComponent | null = null;
+ private hookSelector: HookSelectorComponent | undefined = undefined;
+ private hookInput: HookInputComponent | undefined = undefined;
// Custom tools for custom rendering
private customTools: Map;
@@ -126,10 +126,10 @@ export class InteractiveMode {
constructor(
session: AgentSession,
version: string,
- changelogMarkdown: string | null = null,
+ changelogMarkdown: string | undefined = undefined,
customTools: LoadedCustomTool[] = [],
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
- fdPath: string | null = null,
+ fdPath: string | undefined = undefined,
) {
this.session = session;
this.version = version;
@@ -350,7 +350,7 @@ export class InteractiveMode {
await this.emitToolSessionEvent({
entries,
sessionFile: this.session.sessionFile,
- previousSessionFile: null,
+ previousSessionFile: undefined,
reason: "start",
});
@@ -395,10 +395,9 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
}
- // Emit session event
+ // Emit session_start event
await hookRunner.emit({
- type: "session",
- reason: "start",
+ type: "session_start",
});
}
@@ -442,7 +441,7 @@ export class InteractiveMode {
/**
* Show a selector for hooks.
*/
- private showHookSelector(title: string, options: string[]): Promise {
+ private showHookSelector(title: string, options: string[]): Promise {
return new Promise((resolve) => {
this.hookSelector = new HookSelectorComponent(
title,
@@ -453,7 +452,7 @@ export class InteractiveMode {
},
() => {
this.hideHookSelector();
- resolve(null);
+ resolve(undefined);
},
);
@@ -470,7 +469,7 @@ export class InteractiveMode {
private hideHookSelector(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
- this.hookSelector = null;
+ this.hookSelector = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
@@ -486,7 +485,7 @@ export class InteractiveMode {
/**
* Show a text input for hooks.
*/
- private showHookInput(title: string, placeholder?: string): Promise {
+ private showHookInput(title: string, placeholder?: string): Promise {
return new Promise((resolve) => {
this.hookInput = new HookInputComponent(
title,
@@ -497,7 +496,7 @@ export class InteractiveMode {
},
() => {
this.hideHookInput();
- resolve(null);
+ resolve(undefined);
},
);
@@ -514,7 +513,7 @@ export class InteractiveMode {
private hideHookInput(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
- this.hookInput = null;
+ this.hookInput = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
@@ -874,7 +873,7 @@ export class InteractiveMode {
}
this.pendingTools.clear();
}
- this.streamingComponent = null;
+ this.streamingComponent = undefined;
this.footer.invalidate();
}
this.ui.requestRender();
@@ -920,12 +919,12 @@ export class InteractiveMode {
case "agent_end":
if (this.loadingAnimation) {
this.loadingAnimation.stop();
- this.loadingAnimation = null;
+ this.loadingAnimation = undefined;
this.statusContainer.clear();
}
if (this.streamingComponent) {
this.chatContainer.removeChild(this.streamingComponent);
- this.streamingComponent = null;
+ this.streamingComponent = undefined;
}
this.pendingTools.clear();
this.ui.requestRender();
@@ -964,7 +963,7 @@ export class InteractiveMode {
// Stop loader
if (this.autoCompactionLoader) {
this.autoCompactionLoader.stop();
- this.autoCompactionLoader = null;
+ this.autoCompactionLoader = undefined;
this.statusContainer.clear();
}
// Handle result
@@ -1018,7 +1017,7 @@ export class InteractiveMode {
// Stop loader
if (this.retryLoader) {
this.retryLoader.stop();
- this.retryLoader = null;
+ this.retryLoader = undefined;
this.statusContainer.clear();
}
// Show error only on final failure (success shows normal response)
@@ -1228,10 +1227,9 @@ export class InteractiveMode {
private async shutdown(): Promise {
// Emit shutdown event to hooks
const hookRunner = this.session.hookRunner;
- if (hookRunner?.hasHandlers("session")) {
+ if (hookRunner?.hasHandlers("session_shutdown")) {
await hookRunner.emit({
- type: "session",
- reason: "shutdown",
+ type: "session_shutdown",
});
}
@@ -1265,7 +1263,7 @@ export class InteractiveMode {
private cycleThinkingLevel(): void {
const newLevel = this.session.cycleThinkingLevel();
- if (newLevel === null) {
+ if (newLevel === undefined) {
this.showStatus("Current model does not support thinking");
} else {
this.footer.updateState(this.session.state);
@@ -1277,7 +1275,7 @@ export class InteractiveMode {
private async cycleModel(direction: "forward" | "backward"): Promise {
try {
const result = await this.session.cycleModel(direction);
- if (result === null) {
+ if (result === undefined) {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg);
} else {
@@ -1612,13 +1610,13 @@ export class InteractiveMode {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
- this.loadingAnimation = null;
+ this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Clear UI state
this.pendingMessagesContainer.clear();
- this.streamingComponent = null;
+ this.streamingComponent = undefined;
this.pendingTools.clear();
// Switch session via AgentSession (emits hook and tool session events)
@@ -1874,7 +1872,7 @@ export class InteractiveMode {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
- this.loadingAnimation = null;
+ this.loadingAnimation = undefined;
}
this.statusContainer.clear();
@@ -1884,7 +1882,7 @@ export class InteractiveMode {
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
- this.streamingComponent = null;
+ this.streamingComponent = undefined;
this.pendingTools.clear();
this.chatContainer.addChild(new Spacer(1));
@@ -1962,12 +1960,12 @@ export class InteractiveMode {
}
} catch (error) {
if (this.bashComponent) {
- this.bashComponent.setComplete(null, false);
+ this.bashComponent.setComplete(undefined, false);
}
this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
- this.bashComponent = null;
+ this.bashComponent = undefined;
this.ui.requestRender();
}
@@ -1987,7 +1985,7 @@ export class InteractiveMode {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
- this.loadingAnimation = null;
+ this.loadingAnimation = undefined;
}
this.statusContainer.clear();
@@ -2039,7 +2037,7 @@ export class InteractiveMode {
stop(): void {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
- this.loadingAnimation = null;
+ this.loadingAnimation = undefined;
}
this.footer.dispose();
if (this.unsubscribe) {
diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts
index c8b302c0..fbf3037a 100644
--- a/packages/coding-agent/src/modes/print-mode.ts
+++ b/packages/coding-agent/src/modes/print-mode.ts
@@ -45,10 +45,9 @@ export async function runPrintMode(
hookRunner.setAppendEntryHandler((customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
});
- // Emit session event
+ // Emit session_start event
await hookRunner.emit({
- type: "session",
- reason: "start",
+ type: "session_start",
});
}
@@ -59,7 +58,7 @@ export async function runPrintMode(
await tool.onSession({
entries,
sessionFile: session.sessionFile,
- previousSessionFile: null,
+ previousSessionFile: undefined,
reason: "start",
});
} catch (_err) {
diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
index 9ae57cd3..de378612 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts
@@ -51,17 +51,17 @@ export async function runRpcMode(session: AgentSession): Promise {
* Create a hook UI context that uses the RPC protocol.
*/
const createHookUIContext = (): HookUIContext => ({
- async select(title: string, options: string[]): Promise {
+ async select(title: string, options: string[]): Promise {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
if ("cancelled" in response && response.cancelled) {
- resolve(null);
+ resolve(undefined);
} else if ("value" in response) {
resolve(response.value);
} else {
- resolve(null);
+ resolve(undefined);
}
},
reject,
@@ -89,17 +89,17 @@ export async function runRpcMode(session: AgentSession): Promise {
});
},
- async input(title: string, placeholder?: string): Promise {
+ async input(title: string, placeholder?: string): Promise {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
if ("cancelled" in response && response.cancelled) {
- resolve(null);
+ resolve(undefined);
} else if ("value" in response) {
resolve(response.value);
} else {
- resolve(null);
+ resolve(undefined);
}
},
reject,
@@ -144,10 +144,9 @@ export async function runRpcMode(session: AgentSession): Promise {
hookRunner.setAppendEntryHandler((customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
});
- // Emit session event
+ // Emit session_start event
await hookRunner.emit({
- type: "session",
- reason: "start",
+ type: "session_start",
});
}
@@ -159,7 +158,7 @@ export async function runRpcMode(session: AgentSession): Promise {
await tool.onSession({
entries,
sessionFile: session.sessionFile,
- previousSessionFile: null,
+ previousSessionFile: undefined,
reason: "start",
});
} catch (_err) {
diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts
index 993bc85f..d4f7a73a 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-types.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts
@@ -65,12 +65,12 @@ export type RpcCommand =
// ============================================================================
export interface RpcSessionState {
- model: Model | null;
+ model?: Model;
thinkingLevel: ThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
queueMode: "all" | "one-at-a-time";
- sessionFile: string | null;
+ sessionFile?: string;
sessionId: string;
autoCompactionEnabled: boolean;
messageCount: number;
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
index 8111b3d2..e969950c 100644
--- a/packages/coding-agent/src/utils/tools-manager.ts
+++ b/packages/coding-agent/src/utils/tools-manager.ts
@@ -184,14 +184,14 @@ async function downloadTool(tool: "fd" | "rg"): Promise {
// Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable
-export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise {
+export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise {
const existingPath = getToolPath(tool);
if (existingPath) {
return existingPath;
}
const config = TOOLS[tool];
- if (!config) return null;
+ if (!config) return undefined;
// Tool not found - download it
if (!silent) {
@@ -208,6 +208,6 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr
if (!silent) {
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
}
- return null;
+ return undefined;
}
}
diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts
index 19f1e969..51b3aa59 100644
--- a/packages/coding-agent/test/agent-session-branching.test.ts
+++ b/packages/coding-agent/test/agent-session-branching.test.ts
@@ -100,7 +100,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
createSession(true);
// Verify sessions are disabled
- expect(session.sessionFile).toBeNull();
+ expect(session.sessionFile).toBeUndefined();
// Send one message
await session.prompt("Say hi");
@@ -121,8 +121,8 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
// After branching, conversation should be empty
expect(session.messages.length).toBe(0);
- // Session file should still be null (no file created)
- expect(session.sessionFile).toBeNull();
+ // Session file should still be undefined (no file created)
+ expect(session.sessionFile).toBeUndefined();
});
it("should branch from middle of conversation", async () => {
diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts
index fc79bdc1..529961c4 100644
--- a/packages/coding-agent/test/agent-session-compaction.test.ts
+++ b/packages/coding-agent/test/agent-session-compaction.test.ts
@@ -102,7 +102,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
// First message should be the summary (a user message with summary content)
const firstMsg = messages[0];
- expect(firstMsg.role).toBe("user");
+ expect(firstMsg.role).toBe("compactionSummary");
}, 120000);
it("should maintain valid session state after compaction", async () => {
diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts
index 126cb1a6..fbf1dadd 100644
--- a/packages/coding-agent/test/compaction-hooks-example.test.ts
+++ b/packages/coding-agent/test/compaction-hooks-example.test.ts
@@ -3,18 +3,16 @@
*/
import { describe, expect, it } from "vitest";
-import type { HookAPI } from "../src/core/hooks/index.js";
+import type { HookAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/hooks/index.js";
describe("Documentation example", () => {
it("custom compaction example should type-check correctly", () => {
// This is the example from hooks.md - verify it compiles
const exampleHook = (pi: HookAPI) => {
- pi.on("session", async (event, ctx) => {
- if (event.reason !== "before_compact") return;
-
- // After narrowing, these should all be accessible
- // sessionManager and modelRegistry come from ctx, not event
+ pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => {
+ // All these should be accessible on the event
const { preparation, previousCompactions, model } = event;
+ // sessionManager and modelRegistry come from ctx, not event
const { sessionManager, modelRegistry } = ctx;
const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation;
@@ -51,12 +49,10 @@ describe("Documentation example", () => {
expect(typeof exampleHook).toBe("function");
});
- it("compact event should have correct fields after narrowing", () => {
+ it("compact event should have correct fields", () => {
const checkCompactEvent = (pi: HookAPI) => {
- pi.on("session", async (event, _ctx) => {
- if (event.reason !== "compact") return;
-
- // After narrowing, these should all be accessible
+ pi.on("session_compact", async (event: SessionCompactEvent) => {
+ // These should all be accessible
const entry = event.compactionEntry;
const fromHook = event.fromHook;
diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts
index fe2fff3b..9ca29281 100644
--- a/packages/coding-agent/test/compaction-hooks.test.ts
+++ b/packages/coding-agent/test/compaction-hooks.test.ts
@@ -10,7 +10,13 @@ import { getModel } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.js";
import { AuthStorage } from "../src/core/auth-storage.js";
-import { HookRunner, type LoadedHook, type SessionEvent } from "../src/core/hooks/index.js";
+import {
+ HookRunner,
+ type LoadedHook,
+ type SessionBeforeCompactEvent,
+ type SessionCompactEvent,
+ type SessionEvent,
+} from "../src/core/hooks/index.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
@@ -40,19 +46,25 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
});
function createHook(
- onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compaction?: any } | undefined,
- onCompact?: (event: SessionEvent) => void,
+ onBeforeCompact?: (event: SessionBeforeCompactEvent) => { cancel?: boolean; compaction?: any } | undefined,
+ onCompact?: (event: SessionCompactEvent) => void,
): LoadedHook {
const handlers = new Map Promise)[]>();
- handlers.set("session", [
- async (event: SessionEvent) => {
+ handlers.set("session_before_compact", [
+ async (event: SessionBeforeCompactEvent) => {
capturedEvents.push(event);
-
- if (event.reason === "before_compact" && onBeforeCompact) {
+ if (onBeforeCompact) {
return onBeforeCompact(event);
}
- if (event.reason === "compact" && onCompact) {
+ return undefined;
+ },
+ ]);
+
+ handlers.set("session_compact", [
+ async (event: SessionCompactEvent) => {
+ capturedEvents.push(event);
+ if (onCompact) {
onCompact(event);
}
return undefined;
@@ -89,9 +101,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry);
hookRunner.setUIContext(
{
- select: async () => null,
+ select: async () => undefined,
confirm: async () => false,
- input: async () => null,
+ input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
},
@@ -121,30 +133,28 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
await session.compact();
- const beforeCompactEvents = capturedEvents.filter((e) => e.reason === "before_compact");
- const compactEvents = capturedEvents.filter((e) => e.reason === "compact");
+ const beforeCompactEvents = capturedEvents.filter(
+ (e): e is SessionBeforeCompactEvent => e.type === "session_before_compact",
+ );
+ const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact");
expect(beforeCompactEvents.length).toBe(1);
expect(compactEvents.length).toBe(1);
const beforeEvent = beforeCompactEvents[0];
- if (beforeEvent.reason === "before_compact") {
- expect(beforeEvent.preparation).toBeDefined();
- expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
- expect(beforeEvent.preparation.messagesToSummarize).toBeDefined();
- expect(beforeEvent.preparation.messagesToKeep).toBeDefined();
- expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0);
- expect(beforeEvent.model).toBeDefined();
- // sessionManager and modelRegistry are now on ctx, not event
- }
+ expect(beforeEvent.preparation).toBeDefined();
+ expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0);
+ expect(beforeEvent.preparation.messagesToSummarize).toBeDefined();
+ expect(beforeEvent.preparation.messagesToKeep).toBeDefined();
+ expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0);
+ expect(beforeEvent.model).toBeDefined();
+ // sessionManager and modelRegistry are now on ctx, not event
const afterEvent = compactEvents[0];
- if (afterEvent.reason === "compact") {
- expect(afterEvent.compactionEntry).toBeDefined();
- expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0);
- expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0);
- expect(afterEvent.fromHook).toBe(false);
- }
+ expect(afterEvent.compactionEntry).toBeDefined();
+ expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0);
+ expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0);
+ expect(afterEvent.fromHook).toBe(false);
}, 120000);
it("should allow hooks to cancel compaction", async () => {
@@ -156,7 +166,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
await expect(session.compact()).rejects.toThrow("Compaction cancelled");
- const compactEvents = capturedEvents.filter((e) => e.reason === "compact");
+ const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
expect(compactEvents.length).toBe(0);
}, 120000);
@@ -164,7 +174,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
const customSummary = "Custom summary from hook";
const hook = createHook((event) => {
- if (event.reason === "before_compact") {
+ if (event.type === "session_before_compact") {
return {
compaction: {
summary: customSummary,
@@ -187,11 +197,11 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
expect(result.summary).toBe(customSummary);
- const compactEvents = capturedEvents.filter((e) => e.reason === "compact");
+ const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
expect(compactEvents.length).toBe(1);
const afterEvent = compactEvents[0];
- if (afterEvent.reason === "compact") {
+ if (afterEvent.type === "session_compact") {
expect(afterEvent.compactionEntry.summary).toBe(customSummary);
expect(afterEvent.fromHook).toBe(true);
}
@@ -206,11 +216,11 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
await session.compact();
- const compactEvents = capturedEvents.filter((e) => e.reason === "compact");
+ const compactEvents = capturedEvents.filter((e) => e.type === "session_compact");
expect(compactEvents.length).toBe(1);
const afterEvent = compactEvents[0];
- if (afterEvent.reason === "compact") {
+ if (afterEvent.type === "session_compact") {
// sessionManager is now on ctx, use session.sessionManager directly
const entries = session.sessionManager.getEntries();
const hasCompactionEntry = entries.some((e: { type: string }) => e.type === "compaction");
@@ -224,13 +234,19 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
resolvedPath: "/test/throwing-hook.ts",
handlers: new Map Promise)[]>([
[
- "session",
+ "session_before_compact",
[
- async (event: SessionEvent) => {
+ async (event: SessionBeforeCompactEvent) => {
+ capturedEvents.push(event);
+ throw new Error("Hook intentionally throws");
+ },
+ ],
+ ],
+ [
+ "session_compact",
+ [
+ async (event: SessionCompactEvent) => {
capturedEvents.push(event);
- if (event.reason === "before_compact") {
- throw new Error("Hook intentionally failed");
- }
return undefined;
},
],
@@ -252,12 +268,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
expect(result.summary).toBeDefined();
expect(result.summary.length).toBeGreaterThan(0);
- const compactEvents = capturedEvents.filter((e) => e.reason === "compact");
+ const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact");
expect(compactEvents.length).toBe(1);
-
- if (compactEvents[0].reason === "compact") {
- expect(compactEvents[0].fromHook).toBe(false);
- }
+ expect(compactEvents[0].fromHook).toBe(false);
}, 120000);
it("should call multiple hooks in order", async () => {
@@ -268,15 +281,19 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
resolvedPath: "/test/hook1.ts",
handlers: new Map Promise)[]>([
[
- "session",
+ "session_before_compact",
[
- async (event: SessionEvent) => {
- if (event.reason === "before_compact") {
- callOrder.push("hook1-before");
- }
- if (event.reason === "compact") {
- callOrder.push("hook1-after");
- }
+ async () => {
+ callOrder.push("hook1-before");
+ return undefined;
+ },
+ ],
+ ],
+ [
+ "session_compact",
+ [
+ async () => {
+ callOrder.push("hook1-after");
return undefined;
},
],
@@ -293,15 +310,19 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
resolvedPath: "/test/hook2.ts",
handlers: new Map Promise)[]>([
[
- "session",
+ "session_before_compact",
[
- async (event: SessionEvent) => {
- if (event.reason === "before_compact") {
- callOrder.push("hook2-before");
- }
- if (event.reason === "compact") {
- callOrder.push("hook2-after");
- }
+ async () => {
+ callOrder.push("hook2-before");
+ return undefined;
+ },
+ ],
+ ],
+ [
+ "session_compact",
+ [
+ async () => {
+ callOrder.push("hook2-after");
return undefined;
},
],
@@ -324,12 +345,10 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
}, 120000);
it("should pass correct data in before_compact event", async () => {
- let capturedBeforeEvent: (SessionEvent & { reason: "before_compact" }) | null = null;
+ let capturedBeforeEvent: SessionBeforeCompactEvent | null = null;
const hook = createHook((event) => {
- if (event.reason === "before_compact") {
- capturedBeforeEvent = event;
- }
+ capturedBeforeEvent = event;
return undefined;
});
createSession([hook]);
@@ -370,7 +389,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
const customSummary = "Custom summary with modified values";
const hook = createHook((event) => {
- if (event.reason === "before_compact") {
+ if (event.type === "session_before_compact") {
return {
compaction: {
summary: customSummary,
diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts
index 43f56712..331c3b1b 100644
--- a/packages/coding-agent/test/compaction.test.ts
+++ b/packages/coding-agent/test/compaction.test.ts
@@ -180,9 +180,9 @@ describe("getLastAssistantUsage", () => {
expect(usage!.input).toBe(100);
});
- it("should return null if no assistant messages", () => {
+ it("should return undefined if no assistant messages", () => {
const entries: SessionEntry[] = [createMessageEntry(createUserMessage("Hello"))];
- expect(getLastAssistantUsage(entries)).toBeNull();
+ expect(getLastAssistantUsage(entries)).toBeUndefined();
});
});
@@ -301,8 +301,8 @@ describe("buildSessionContext", () => {
const loaded = buildSessionContext(entries);
// summary + kept (u2, a2) + after (u3, a3) = 5
expect(loaded.messages.length).toBe(5);
- expect(loaded.messages[0].role).toBe("user");
- expect((loaded.messages[0] as any).content).toContain("Summary of 1,a,2,b");
+ expect(loaded.messages[0].role).toBe("compactionSummary");
+ expect((loaded.messages[0] as any).summary).toContain("Summary of 1,a,2,b");
});
it("should handle multiple compactions (only latest matters)", () => {
@@ -325,7 +325,7 @@ describe("buildSessionContext", () => {
const loaded = buildSessionContext(entries);
// summary + kept from u3 (u3, c) + after (u4, d) = 5
expect(loaded.messages.length).toBe(5);
- expect((loaded.messages[0] as any).content).toContain("Second summary");
+ expect((loaded.messages[0] as any).summary).toContain("Second summary");
});
it("should keep all messages when firstKeptEntryId is first entry", () => {
@@ -443,8 +443,8 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => {
// Should have summary + kept messages
expect(reloaded.messages.length).toBeLessThan(loaded.messages.length);
- expect(reloaded.messages[0].role).toBe("user");
- expect((reloaded.messages[0] as any).content).toContain(compactionResult.summary);
+ expect(reloaded.messages[0].role).toBe("compactionSummary");
+ expect((reloaded.messages[0] as any).summary).toContain(compactionResult.summary);
console.log("Original messages:", loaded.messages.length);
console.log("After compaction:", reloaded.messages.length);
diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts
index 59162d6f..0b7b47ca 100644
--- a/packages/coding-agent/test/model-resolver.test.ts
+++ b/packages/coding-agent/test/model-resolver.test.ts
@@ -66,21 +66,21 @@ describe("parseModelPattern", () => {
const result = parseModelPattern("claude-sonnet-4-5", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("off");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("partial match returns best model", () => {
const result = parseModelPattern("sonnet", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("off");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("no match returns null model", () => {
const result = parseModelPattern("nonexistent", allModels);
- expect(result.model).toBeNull();
+ expect(result.model).toBeUndefined();
expect(result.thinkingLevel).toBe("off");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
});
@@ -89,14 +89,14 @@ describe("parseModelPattern", () => {
const result = parseModelPattern("sonnet:high", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("high");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("gpt-4o:medium returns gpt-4o with medium thinking level", () => {
const result = parseModelPattern("gpt-4o:medium", allModels);
expect(result.model?.id).toBe("gpt-4o");
expect(result.thinkingLevel).toBe("medium");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("all valid thinking levels work", () => {
@@ -104,7 +104,7 @@ describe("parseModelPattern", () => {
const result = parseModelPattern(`sonnet:${level}`, allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(level);
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
}
});
});
@@ -131,7 +131,7 @@ describe("parseModelPattern", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe("off");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("openrouter/qwen/qwen3-coder:exacto matches with provider prefix", () => {
@@ -139,14 +139,14 @@ describe("parseModelPattern", () => {
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.model?.provider).toBe("openrouter");
expect(result.thinkingLevel).toBe("off");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("qwen3-coder:exacto:high matches model with high thinking level", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe("high");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("openrouter/qwen/qwen3-coder:exacto:high matches with provider and thinking level", () => {
@@ -154,14 +154,14 @@ describe("parseModelPattern", () => {
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.model?.provider).toBe("openrouter");
expect(result.thinkingLevel).toBe("high");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
test("gpt-4o:extended matches the extended model", () => {
const result = parseModelPattern("openai/gpt-4o:extended", allModels);
expect(result.model?.id).toBe("openai/gpt-4o:extended");
expect(result.thinkingLevel).toBe("off");
- expect(result.warning).toBeNull();
+ expect(result.warning).toBeUndefined();
});
});
diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts
index 8c0b3aac..e0d6edac 100644
--- a/packages/coding-agent/test/rpc.test.ts
+++ b/packages/coding-agent/test/rpc.test.ts
@@ -273,7 +273,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T
// Initially null
let text = await client.getLastAssistantText();
- expect(text).toBeNull();
+ expect(text).toBeUndefined();
// Send prompt
await client.promptAndWait("Reply with just: test123");
diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts
index 31479b7b..5bc336a6 100644
--- a/packages/coding-agent/test/session-manager/build-context.test.ts
+++ b/packages/coding-agent/test/session-manager/build-context.test.ts
@@ -48,7 +48,7 @@ function compaction(id: string, parentId: string | null, summary: string, firstK
};
}
-function branchSummary(id: string, parentId: string | null, fromId: string, summary: string): BranchSummaryEntry {
+function branchSummary(id: string, parentId: string | null, summary: string, fromId: string): BranchSummaryEntry {
return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary, fromId };
}
@@ -132,7 +132,7 @@ describe("buildSessionContext", () => {
// Should have: summary + kept (3,4) + after (6,7) = 5 messages
expect(ctx.messages).toHaveLength(5);
- expect((ctx.messages[0] as any).content).toContain("Summary of first two turns");
+ expect((ctx.messages[0] as any).summary).toContain("Summary of first two turns");
expect((ctx.messages[1] as any).content).toBe("second");
expect((ctx.messages[2] as any).content[0].text).toBe("response2");
expect((ctx.messages[3] as any).content).toBe("third");
@@ -150,7 +150,7 @@ describe("buildSessionContext", () => {
// Summary + all messages (1,2,4)
expect(ctx.messages).toHaveLength(4);
- expect((ctx.messages[0] as any).content).toContain("Empty summary");
+ expect((ctx.messages[0] as any).summary).toContain("Empty summary");
});
it("multiple compactions uses latest", () => {
@@ -167,7 +167,7 @@ describe("buildSessionContext", () => {
// Should use second summary, keep from 4
expect(ctx.messages).toHaveLength(4);
- expect((ctx.messages[0] as any).content).toContain("Second summary");
+ expect((ctx.messages[0] as any).summary).toContain("Second summary");
});
});
@@ -203,7 +203,7 @@ describe("buildSessionContext", () => {
const ctx = buildSessionContext(entries, "5");
expect(ctx.messages).toHaveLength(4);
- expect((ctx.messages[2] as any).content).toContain("Summary of abandoned work");
+ expect((ctx.messages[2] as any).summary).toContain("Summary of abandoned work");
expect((ctx.messages[3] as any).content).toBe("new direction");
});
@@ -231,7 +231,7 @@ describe("buildSessionContext", () => {
// Main path to 7: summary + kept(3,4) + after(6,7)
const ctxMain = buildSessionContext(entries, "7");
expect(ctxMain.messages).toHaveLength(5);
- expect((ctxMain.messages[0] as any).content).toContain("Compacted history");
+ expect((ctxMain.messages[0] as any).summary).toContain("Compacted history");
expect((ctxMain.messages[1] as any).content).toBe("q2");
expect((ctxMain.messages[2] as any).content[0].text).toBe("r2");
expect((ctxMain.messages[3] as any).content).toBe("q3");
@@ -243,7 +243,7 @@ describe("buildSessionContext", () => {
expect((ctxBranch.messages[0] as any).content).toBe("start");
expect((ctxBranch.messages[1] as any).content[0].text).toBe("r1");
expect((ctxBranch.messages[2] as any).content).toBe("q2");
- expect((ctxBranch.messages[3] as any).content).toContain("Tried wrong approach");
+ expect((ctxBranch.messages[3] as any).summary).toContain("Tried wrong approach");
expect((ctxBranch.messages[4] as any).content).toBe("better approach");
});
});
diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts
index fa788164..8cf1dcde 100644
--- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts
+++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts
@@ -451,7 +451,7 @@ describe("createBranchedSession", () => {
// Create branched session from id2 (should only have 1 -> 2)
const result = session.createBranchedSession(id2);
- expect(result).toBeNull(); // in-memory returns null
+ expect(result).toBeUndefined(); // in-memory returns null
// Session should now only have entries 1 and 2
const entries = session.getEntries();