# Hooks

Hooks are TypeScript modules that extend the coding agent's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user for input, modify results, and more.

**Example use cases:**
- Block dangerous commands (permission gates for `rm -rf`, `sudo`, etc.)
- Checkpoint code state (git stash at each turn, restore on `/branch`)
- Protect paths (block writes to `.env`, `node_modules/`, etc.)
- Modify tool output (filter or transform results before the LLM sees them)
- Inject messages from external sources (file watchers, webhooks, CI systems)

See [examples/hooks/](../examples/hooks/) for working implementations.

## Hook Locations

Hooks are automatically discovered from two locations:

1. **Global hooks**: `~/.pi/agent/hooks/*.ts`
2. **Project hooks**: `<cwd>/.pi/hooks/*.ts`

All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`).

You can also load a specific hook file directly using the `--hook` flag:

```bash
pi --hook ./my-hook.ts
```

This is useful for testing hooks without placing them in the standard directories.

### Additional Configuration

You can also add explicit hook paths in `~/.pi/agent/settings.json`:

```json
{
  "hooks": [
    "/path/to/custom/hook.ts"
  ],
  "hookTimeout": 30000
}
```

- `hooks`: Additional hook file paths (supports `~` expansion)
- `hookTimeout`: Timeout in milliseconds for hook operations (default: 30000). Does not apply to `tool_call` events, which have no timeout since they may prompt the user.

## Available Imports

Hooks can import from these packages (automatically resolved by pi):

| Package | Purpose |
|---------|---------|
| `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, etc.) |
| `@mariozechner/pi-coding-agent` | Additional types if needed |
| `@mariozechner/pi-ai` | AI utilities (`ToolResultMessage`, etc.) |
| `@mariozechner/pi-tui` | TUI components (for advanced use cases) |
| `@sinclair/typebox` | Schema definitions |

Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.

## Writing a Hook

A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events.

```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";

export default function (pi: HookAPI) {
  pi.on("session", async (event, ctx) => {
    ctx.ui.notify(`Session ${event.reason}: ${ctx.sessionFile ?? "ephemeral"}`, "info");
  });
}
```

### Setup

Create a hooks directory:

```bash
# Global hooks
mkdir -p ~/.pi/agent/hooks

# Or project-local hooks
mkdir -p .pi/hooks
```

Then create `.ts` files directly in these directories. Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. The import from `@mariozechner/pi-coding-agent/hooks` resolves to the globally installed package automatically.

## Events

### Lifecycle

```
pi starts
  │
  ├─► session (reason: "start")
  │
  ▼
user sends prompt ─────────────────────────────────────────┐
  │                                                        │
  ├─► agent_start                                          │
  │                                                        │
  │   ┌─── turn (repeats while LLM calls tools) ───┐       │
  │   │                                            │       │
  │   ├─► turn_start                               │       │
  │   │                                            │       │
  │   │   LLM responds, may call tools:            │       │
  │   │     ├─► tool_call (can block)              │       │
  │   │     │   tool executes                      │       │
  │   │     └─► tool_result (can modify)           │       │
  │   │                                            │       │
  │   └─► turn_end                                 │       │
  │                                                        │
  └─► agent_end                                            │
                                                           │
user sends another prompt ◄────────────────────────────────┘

user branches (/branch)
  │
  ├─► session (reason: "before_branch", can cancel)
  └─► session (reason: "branch", AFTER branch)

user switches session (/resume)
  │
  ├─► session (reason: "before_switch", can cancel)
  └─► session (reason: "switch", AFTER switch)

user clears session (/clear)
  │
  ├─► session (reason: "before_clear", can cancel)
  └─► session (reason: "clear", AFTER clear)

context compaction (auto or /compact)
  │
  ├─► session (reason: "before_compact", can cancel or provide custom summary)
  └─► session (reason: "compact", AFTER compaction)

user exits (double Ctrl+C or Ctrl+D)
  │
  └─► session (reason: "shutdown")
```

A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools.

### session

Fired on session lifecycle events. The `before_*` variants fire before the action and can be cancelled by returning `{ cancel: true }`.

```typescript
pi.on("session", async (event, ctx) => {
  // event.entries: SessionEntry[] - all session entries
  // event.sessionFile: string | null - current session file (null with --no-session)
  // event.previousSessionFile: string | null - previous session file
  // event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" |
  //               "before_branch" | "branch" | "before_compact" | "compact" | "shutdown"
  // event.targetTurnIndex: number - only for "before_branch" and "branch"

  // Cancel a before_* action:
  if (event.reason === "before_clear") {
    return { cancel: true };
  }

  // For before_branch only: create branch but skip conversation restore
  // (useful for checkpoint hooks that restore files separately)
  if (event.reason === "before_branch") {
    return { skipConversationRestore: true };
  }
});
```

**Reasons:**
- `start`: Initial session load on startup
- `before_switch` / `switch`: User switched sessions (`/resume`)
- `before_clear` / `clear`: User cleared the session (`/clear`)
- `before_branch` / `branch`: User branched the session (`/branch`)
- `before_compact` / `compact`: Context compaction (auto or `/compact`)
- `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM)

For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from.

#### Custom Compaction

The `before_compact` event lets you implement custom compaction strategies. Understanding the data model:

**How default compaction works:**

When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k):

```
Legend:
  hdr  = header           usr  = user message       ass = assistant message
  tool = tool result      cmp  = compaction entry   bash = bashExecution
```

```
Session entries (before compaction):

  index:   0     1     2     3      4     5     6      7      8     9     10
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘
                ↑     └───────┬───────┘ └────────────┬────────────┘
         previousSummary  messagesToSummarize     messagesToKeep
                                   ↑
                    cutPoint.firstKeptEntryIndex = 5

After compaction (new entry appended):

  index:   0     1     2     3      4     5     6      7      8     9     10    11
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘
               └──────────┬───────────┘ └────────────────────────┬─────────────────┘
                  not sent to LLM                           sent to LLM
                                          ↑
                                firstKeptEntryIndex = 5
                                  (stored in new cmp)
```

The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call).

```
What gets sent to the LLM as context:

                       5     6      7      8     9     10
  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
                 ↑      └─────────────────┬────────────────┘
       from new cmp's              messages from
           summary           firstKeptEntryIndex onwards
```

**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`:

```
Split turn example (one huge turn that exceeds keepRecentTokens):

  index:   0     1     2      3     4      5      6     7      8     9
        ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┬─────┐
        │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │ ass │
        └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┴─────┘
                ↑                                       ↑
         turnStartIndex = 1                   firstKeptEntryIndex = 7
                │                                       │ (must be usr/ass/bash, not tool)
                └─────────── turn prefix ───────────────┘ (idx 1-6, summarized separately)
                                                        └── kept messages (idx 7-9)

  messagesToSummarize = []  (no complete turns before this one)
  messagesToKeep = [ass idx 7, tool idx 8, ass idx 9]

The default compaction generates TWO summaries that get merged:
1. History summary (previousSummary + messagesToSummarize)
2. Turn prefix summary (messages from turnStartIndex to firstKeptEntryIndex)
```

See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation.

**Event fields:**

| Field | Description |
|-------|-------------|
| `entries` | All session entries (header, messages, model changes, previous compactions). Use this for custom schemes that need full session history. |
| `cutPoint` | Where default compaction would cut. `firstKeptEntryIndex` is the entry index where kept messages start. `isSplitTurn` indicates if cutting mid-turn. |
| `previousSummary` | Summary from the last compaction, if any. Include this in your summary to preserve accumulated context. |
| `messagesToSummarize` | Messages that will be summarized and discarded (from after last compaction to cut point). |
| `messagesToKeep` | Messages that will be kept verbatim after the summary (from cut point to end). |
| `tokensBefore` | Current context token count (why compaction triggered). |
| `model` | Model to use for summarization. |
| `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
| `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |

**Common patterns:**

```typescript
// Get all messages since last compaction
const allMessages = [...event.messagesToSummarize, ...event.messagesToKeep];

// Default behavior: summarize old, keep recent
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Your custom summary...",
    firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
    tokensBefore: event.tokensBefore,
  }
};

// Full compaction: summarize everything, keep nothing
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Complete summary of all work...",
    firstKeptEntryIndex: event.entries.length, // discard all messages
    tokensBefore: event.tokensBefore,
  }
};
```

See [examples/hooks/full-compaction.ts](../examples/hooks/full-compaction.ts) for a complete example.

**After compaction (`compact` event):**
- `event.compactionEntry`: The saved compaction entry
- `event.tokensBefore`: Token count before compaction
- `event.fromHook`: Whether the compaction entry was provided by a hook

### agent_start / agent_end

Fired once per user prompt.

```typescript
pi.on("agent_start", async (event, ctx) => {});

pi.on("agent_end", async (event, ctx) => {
  // event.messages: AppMessage[] - new messages from this prompt
});
```

### turn_start / turn_end

Fired for each turn within an agent loop.

```typescript
pi.on("turn_start", async (event, ctx) => {
  // event.turnIndex: number
  // event.timestamp: number
});

pi.on("turn_end", async (event, ctx) => {
  // event.turnIndex: number
  // event.message: AppMessage - assistant's response
  // event.toolResults: ToolResultMessage[] - tool results from this turn
});
```

### tool_call

Fired before tool executes. **Can block.** No timeout (user prompts can take any time).

```typescript
pi.on("tool_call", async (event, ctx) => {
  // event.toolName: string (built-in or custom tool name)
  // event.toolCallId: string
  // event.input: Record<string, unknown>
  return { block: true, reason: "..." }; // or undefined to allow
});
```

Built-in tool inputs:
- `bash`: `{ command, timeout? }`
- `read`: `{ path, offset?, limit? }`
- `write`: `{ path, content }`
- `edit`: `{ path, oldText, newText }`
- `ls`: `{ path?, limit? }`
- `find`: `{ pattern, path?, limit? }`
- `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }`

Custom tools are also intercepted with their own names and input schemas.

### tool_result

Fired after tool executes. **Can modify result.**

```typescript
pi.on("tool_result", async (event, ctx) => {
  // event.toolName: string
  // event.toolCallId: string
  // event.input: Record<string, unknown>
  // event.content: (TextContent | ImageContent)[]
  // event.details: tool-specific (see below)
  // event.isError: boolean

  // Return modified content/details, or undefined to keep original
  return { content: [...], details: {...} };
});
```

The event type is a discriminated union based on `toolName`. Use the provided type guards to narrow `details` to the correct type:

```typescript
import { isBashToolResult, type HookAPI } from "@mariozechner/pi-coding-agent/hooks";

export default function (pi: HookAPI) {
  pi.on("tool_result", async (event, ctx) => {
    if (isBashToolResult(event)) {
      // event.details is BashToolDetails | undefined
      if (event.details?.truncation?.truncated) {
        // Access full output from temp file
        const fullPath = event.details.fullOutputPath;
      }
    }
  });
}
```

Available type guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`.

#### Tool Details Types

Each built-in tool has a typed `details` field. Types are exported from `@mariozechner/pi-coding-agent`:

| Tool | Details Type | Source |
|------|-------------|--------|
| `bash` | `BashToolDetails` | `src/core/tools/bash.ts` |
| `read` | `ReadToolDetails` | `src/core/tools/read.ts` |
| `edit` | `undefined` | - |
| `write` | `undefined` | - |
| `grep` | `GrepToolDetails` | `src/core/tools/grep.ts` |
| `find` | `FindToolDetails` | `src/core/tools/find.ts` |
| `ls` | `LsToolDetails` | `src/core/tools/ls.ts` |

Common fields in details:
- `truncation?: TruncationResult` - present when output was truncated
- `fullOutputPath?: string` - path to temp file with full output (bash only)

`TruncationResult` contains:
- `truncated: boolean` - whether truncation occurred
- `truncatedBy: "lines" | "bytes" | null` - which limit was hit
- `totalLines`, `totalBytes` - original size
- `outputLines`, `outputBytes` - truncated size

Custom tools use `CustomToolResultEvent` with `details: unknown`. Create your own type guard to get full type safety:

```typescript
import {
  isBashToolResult,
  type CustomToolResultEvent,
  type HookAPI,
  type ToolResultEvent,
} from "@mariozechner/pi-coding-agent/hooks";

interface MyCustomToolDetails {
  someField: string;
}

// Type guard that narrows both toolName and details
function isMyCustomToolResult(e: ToolResultEvent): e is CustomToolResultEvent & {
  toolName: "my-custom-tool";
  details: MyCustomToolDetails;
} {
  return e.toolName === "my-custom-tool";
}

export default function (pi: HookAPI) {
  pi.on("tool_result", async (event, ctx) => {
    // Built-in tool: use provided type guard
    if (isBashToolResult(event)) {
      if (event.details?.fullOutputPath) {
        console.log(`Full output at: ${event.details.fullOutputPath}`);
      }
    }

    // Custom tool: use your own type guard
    if (isMyCustomToolResult(event)) {
      // event.details is now MyCustomToolDetails
      console.log(event.details.someField);
    }
  });
}
```

**Note:** If you modify `content`, you should also update `details` accordingly. The TUI uses `details` (e.g., truncation info) for rendering, so inconsistent values will cause display issues.

## Context API

Every event handler receives a context object with these methods:

### ctx.ui.select(title, options)

Show a selector dialog. Returns the selected option or `null` if cancelled.

```typescript
const choice = await ctx.ui.select("Pick one:", ["Option A", "Option B"]);
if (choice === "Option A") {
  // ...
}
```

### ctx.ui.confirm(title, message)

Show a confirmation dialog. Returns `true` if confirmed, `false` otherwise.

```typescript
const confirmed = await ctx.ui.confirm("Delete file?", "This cannot be undone.");
if (confirmed) {
  // ...
}
```

### ctx.ui.input(title, placeholder?)

Show a text input dialog. Returns the input string or `null` if cancelled.

```typescript
const name = await ctx.ui.input("Enter name:", "default value");
```

### ctx.ui.notify(message, type?)

Show a notification. Type can be `"info"`, `"warning"`, or `"error"`.

```typescript
ctx.ui.notify("Operation complete", "info");
ctx.ui.notify("Something went wrong", "error");
```

### ctx.exec(command, args, options?)

Execute a command and get the result. Supports cancellation via `AbortSignal` and timeout.

```typescript
const result = await ctx.exec("git", ["status"]);
// result.stdout: string
// result.stderr: string
// result.code: number
// result.killed?: boolean  // True if killed by signal/timeout

// With timeout (5 seconds)
const result = await ctx.exec("slow-command", [], { timeout: 5000 });

// With abort signal
const controller = new AbortController();
const result = await ctx.exec("long-command", [], { signal: controller.signal });
```

### ctx.cwd

The current working directory.

```typescript
console.log(`Working in: ${ctx.cwd}`);
```

### ctx.sessionFile

Path to the current session file, or `null` when running with `--no-session` (ephemeral mode).

```typescript
if (ctx.sessionFile) {
  console.log(`Session: ${ctx.sessionFile}`);
}
```

### ctx.hasUI

Whether interactive UI is available. `false` in print and RPC modes.

```typescript
if (ctx.hasUI) {
  const choice = await ctx.ui.select("Pick:", ["A", "B"]);
} else {
  // Fall back to default behavior
}
```

## Sending Messages

Hooks can inject messages into the agent session using `pi.send()`. This is useful for:

- Waking up the agent when an external event occurs (file change, CI result, etc.)
- Async debugging (inject debug output from other processes)
- Triggering agent actions from external systems

```typescript
pi.send(text: string, attachments?: Attachment[]): void
```

If the agent is currently streaming, the message is queued. Otherwise, a new agent loop starts immediately.

### Example: File Watcher

```typescript
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;

    // Watch a trigger file
    const triggerFile = "/tmp/agent-trigger.txt";

    fs.watch(triggerFile, () => {
      try {
        const content = fs.readFileSync(triggerFile, "utf-8").trim();
        if (content) {
          pi.send(`External trigger: ${content}`);
          fs.writeFileSync(triggerFile, ""); // Clear after reading
        }
      } catch {
        // File might not exist yet
      }
    });

    ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info");
  });
}
```

To trigger: `echo "Run the tests" > /tmp/agent-trigger.txt`

### Example: HTTP Webhook

```typescript
import * as http from "node:http";
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;

    const server = http.createServer((req, res) => {
      let body = "";
      req.on("data", chunk => body += chunk);
      req.on("end", () => {
        pi.send(body || "Webhook triggered");
        res.writeHead(200);
        res.end("OK");
      });
    });

    server.listen(3333, () => {
      ctx.ui.notify("Webhook listening on http://localhost:3333", "info");
    });
  });
}
```

To trigger: `curl -X POST http://localhost:3333 -d "CI build failed"`

**Note:** `pi.send()` is not supported in print mode (single-shot execution).

## Examples

### Shitty Permission Gate

```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";

export default function (pi: HookAPI) {
  const dangerousPatterns = [
    /\brm\s+(-rf?|--recursive)/i,
    /\bsudo\b/i,
    /\b(chmod|chown)\b.*777/i,
  ];

  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") return undefined;

    const command = event.input.command as string;
    const isDangerous = dangerousPatterns.some((p) => p.test(command));

    if (isDangerous) {
      const choice = await ctx.ui.select(
        `⚠ Dangerous command:\n\n  ${command}\n\nAllow?`,
        ["Yes", "No"]
      );

      if (choice !== "Yes") {
        return { block: true, reason: "Blocked by user" };
      }
    }

    return undefined;
  });
}
```

### Git Checkpointing

Stash code state at each turn so `/branch` can restore it.

```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";

export default function (pi: HookAPI) {
  const checkpoints = new Map<number, string>();

  pi.on("turn_start", async (event, ctx) => {
    // Create a git stash entry before LLM makes changes
    const { stdout } = await ctx.exec("git", ["stash", "create"]);
    const ref = stdout.trim();
    if (ref) {
      checkpoints.set(event.turnIndex, ref);
    }
  });

  pi.on("session", async (event, ctx) => {
    // Only handle before_branch events
    if (event.reason !== "before_branch") return;

    const ref = checkpoints.get(event.targetTurnIndex);
    if (!ref) return;

    const choice = await ctx.ui.select("Restore code state?", [
      "Yes, restore code to that point",
      "No, keep current code",
    ]);

    if (choice?.startsWith("Yes")) {
      await ctx.exec("git", ["stash", "apply", ref]);
      ctx.ui.notify("Code restored to checkpoint", "info");
    }
  });

  pi.on("agent_end", async () => {
    checkpoints.clear();
  });
}
```

### Block Writes to Certain Paths

```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";

export default function (pi: HookAPI) {
  const protectedPaths = [".env", ".git/", "node_modules/"];

  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "write" && event.toolName !== "edit") {
      return undefined;
    }

    const path = event.input.path as string;
    const isProtected = protectedPaths.some((p) => path.includes(p));

    if (isProtected) {
      ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
      return { block: true, reason: `Path "${path}" is protected` };
    }

    return undefined;
  });
}
```

### Custom Compaction

Use a cheaper model for summarization, or implement your own compaction strategy.

```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { CompactionEntry } from "@mariozechner/pi-coding-agent";

export default function (pi: HookAPI) {
  pi.on("session", async (event, ctx) => {
    if (event.reason !== "before_compact") return;

    // Example: Use a simpler summarization approach
    const messages = event.messagesToSummarize;
    const summary = messages
      .filter((m) => m.role === "user")
      .map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`)
      .join("\n");

    const compactionEntry: CompactionEntry = {
      type: "compaction",
      timestamp: new Date().toISOString(),
      summary: `User requests:\n${summary}`,
      firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex,
      tokensBefore: event.tokensBefore,
    };

    return { compactionEntry };
  });
}
```

## Mode Behavior

Hooks behave differently depending on the run mode:

| Mode | UI Methods | Notes |
|------|-----------|-------|
| Interactive | Full TUI dialogs | User can interact normally |
| RPC | JSON protocol | Host application handles UI |
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |

In print mode, `select()` returns `null`, `confirm()` returns `false`, and `input()` returns `null`. Design hooks to handle these cases gracefully.

## Error Handling

- If a hook throws an error, it's logged and the agent continues
- If a `tool_call` hook throws an error, the tool is **blocked** (fail-safe)
- Other events have a timeout (default 30s); timeout errors are logged but don't block
- Hook errors are displayed in the UI with the hook path and error message

## Debugging

To debug a hook:

1. Open VS Code in your hooks directory
2. Open a **JavaScript Debug Terminal** (Ctrl+Shift+P → "JavaScript Debug Terminal")
3. Set breakpoints in your hook file
4. Run `pi --hook ./my-hook.ts` in the debug terminal

The `--hook` flag loads a hook directly without needing to modify `settings.json` or place files in the standard hook directories.

---

# Internals

## Discovery and Loading

Hooks are discovered and loaded at startup in `main.ts`:

```
main.ts
  -> discoverAndLoadHooks(configuredPaths, cwd)  [loader.ts]
     -> discoverHooksInDir(~/.pi/agent/hooks/)   # global hooks
     -> discoverHooksInDir(cwd/.pi/hooks/)       # project hooks
     -> merge with configuredPaths (deduplicated)
     -> for each path:
        -> jiti.import(path)                     # TypeScript support via jiti
        -> hookFactory(hookAPI)                  # calls pi.on() to register handlers
        -> returns LoadedHook { path, handlers: Map<eventType, handlers[]> }
```

## Tool Wrapping

Tools (built-in and custom) are wrapped with hook callbacks after tool discovery/selection, before the agent is created:

```
main.ts
  -> wrapToolsWithHooks(tools, hookRunner)  [tool-wrapper.ts]
     -> returns new tools with wrapped execute() functions
```

The wrapped `execute()` function:

1. Checks `hookRunner.hasHandlers("tool_call")`
2. If yes, calls `hookRunner.emitToolCall(event)` (no timeout)
3. If result has `block: true`, throws an error
4. Otherwise, calls the original `tool.execute()`
5. Checks `hookRunner.hasHandlers("tool_result")`
6. If yes, calls `hookRunner.emit(event)` (with timeout)
7. Returns (possibly modified) result

## HookRunner

The `HookRunner` class manages hook execution:

```typescript
class HookRunner {
  constructor(hooks: LoadedHook[], cwd: string, timeout?: number)

  setUIContext(ctx: HookUIContext, hasUI: boolean): void
  setSessionFile(path: string | null): void
  onError(listener): () => void
  hasHandlers(eventType: string): boolean
  emit(event: HookEvent): Promise<Result>
  emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined>
}
```

Key behaviors:
- `emit()` has a timeout (default 30s) for safety
- `emitToolCall()` has **no timeout** (user prompts can take any time)
- Errors in `emit()` are caught, logged via `onError()`, and execution continues
- Errors in `emitToolCall()` propagate, causing the tool to be blocked (fail-safe)

## Event Flow

```
Mode initialization:
  -> hookRunner.setUIContext(ctx, hasUI)
  -> hookRunner.setSessionFile(path)
  -> hookRunner.emit({ type: "session", reason: "start", ... })

User sends prompt:
  -> AgentSession.prompt()
     -> hookRunner.emit({ type: "agent_start" })
     -> hookRunner.emit({ type: "turn_start", turnIndex })
     -> agent loop:
        -> LLM generates tool calls
        -> For each tool call:
           -> wrappedTool.execute()
              -> hookRunner.emitToolCall({ type: "tool_call", ... })
              -> [if not blocked] originalTool.execute()
              -> hookRunner.emit({ type: "tool_result", ... })
        -> LLM generates response
     -> hookRunner.emit({ type: "turn_end", ... })
     -> [repeat if more tool calls]
  -> hookRunner.emit({ type: "agent_end", messages })

Branch:
  -> AgentSession.branch()
     -> hookRunner.emit({ type: "session", reason: "before_branch", ... })  # can cancel
     -> [if not cancelled: branch happens]
     -> hookRunner.emit({ type: "session", reason: "branch", ... })

Session switch:
  -> AgentSession.switchSession()
     -> hookRunner.emit({ type: "session", reason: "before_switch", ... })  # can cancel
     -> [if not cancelled: switch happens]
     -> hookRunner.emit({ type: "session", reason: "switch", ... })

Clear:
  -> AgentSession.reset()
     -> hookRunner.emit({ type: "session", reason: "before_clear", ... })  # can cancel
     -> [if not cancelled: clear happens]
     -> hookRunner.emit({ type: "session", reason: "clear", ... })

Shutdown (interactive mode):
  -> handleCtrlC() or handleCtrlD()
     -> hookRunner.emit({ type: "session", reason: "shutdown", ... })
     -> process.exit(0)
```

## UI Context by Mode

Each mode provides its own `HookUIContext` implementation:

**Interactive Mode** (`interactive-mode.ts`):
- `select()` -> `HookSelectorComponent` (TUI list selector)
- `confirm()` -> `HookSelectorComponent` with Yes/No options
- `input()` -> `HookInputComponent` (TUI text input)
- `notify()` -> Adds text to chat container

**RPC Mode** (`rpc-mode.ts`):
- All methods send JSON requests via stdout
- Waits for JSON responses via stdin
- Host application renders UI and sends responses

**Print Mode** (`print-mode.ts`):
- All methods return null/false immediately
- `notify()` is a no-op

## File Structure

```
packages/coding-agent/src/core/hooks/
├── index.ts          # Public exports
├── types.ts          # Event types, HookAPI, contexts
├── loader.ts         # jiti-based hook loading
├── runner.ts         # HookRunner class
└── tool-wrapper.ts   # Tool wrapping for interception
```


The docs look good. I notice a few things that could be updated based on our session manager refactor:

1. Line 151 mentions `event.entries: SessionEntry[]` - this is still correct
2. The common patterns section references `event.entries.length` which is still valid

The docs don't reference `loadSessionFromEntries` or `loadEntries` anywhere, so no changes needed there.

Is there anything specific you want me to update or fix in the hooks.md?


what about @packages/coding-agent/examples/hooks/full-compaction.ts ?


/**
 * Full Context Compaction Hook
 *
 * Replaces the default compaction behavior with a full summary of the entire context.
 * Instead of keeping the last 20k tokens of conversation turns, this hook:
 * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep)
 * 2. Discards all old turns completely, keeping only the summary
 *
 * This is useful when you want maximum context window space for new work
 * at the cost of losing exact conversation history.
 *
 * Usage:
 *   pi --hook examples/hooks/full-compaction.ts
 */

import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } 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;

		const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, model, resolveApiKey, entries } = event;

		// Combine all messages for full summary
		const allMessages = [...messagesToSummarize, ...messagesToKeep];

		ctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, "info");

		// Resolve API key for the model
		const apiKey = await resolveApiKey(model);
		if (!apiKey) {
			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
			return;
		}

		// Transform app messages to LLM-compatible format
		const transformedMessages = messageTransformer(allMessages);

		// Include previous summary context if available
		const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";

		// Build messages that ask for a comprehensive summary
		const summaryMessages = [
			...transformedMessages,
			{
				role: "user" as const,
				content: [
					{
						type: "text" as const,
						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}

1. The main goals and objectives discussed
2. Key decisions made and their rationale
3. Important code changes, file modifications, or technical details
4. Current state of any ongoing work
5. Any blockers, issues, or open questions
6. Next steps that were planned or suggested

Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.

Format the summary as structured markdown with clear sections.`,
					},
				],
				timestamp: Date.now(),
			},
		];

		try {
			// Use the same model with resolved API key
			const response = await complete(model, { messages: summaryMessages }, { apiKey, maxTokens: 8192 });

			const summary = response.content
				.filter((c): c is { type: "text"; text: string } => c.type === "text")
				.map((c) => c.text)
				.join("\n");

			if (!summary.trim()) {
				ctx.ui.notify("Compaction summary was empty, using default compaction", "warning");
				return; // Fall back to default compaction
			}

			// Return a compaction entry that discards ALL messages
			// firstKeptEntryIndex points past all current entries
			return {
				compactionEntry: {
					type: "compaction" as const,
					timestamp: new Date().toISOString(),
					summary,
					firstKeptEntryIndex: entries.length,
					tokensBefore,
				},
			};
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			ctx.ui.notify(`Compaction failed: ${message}`, "error");
			// Fall back to default compaction on error
			return;
		}
	});
}


The example looks good. It uses `entries.length` for `firstKeptEntryIndex` which is correct for full compaction. The `entries` comes from the event, not from SessionManager directly, so no changes needed.

The example is correctly using:
- `messagesToSummarize`, `messagesToKeep`, `previousSummary` from the event
- `resolveApiKey(model)` to get the API key
- `entries.length` to discard all messages

Looks complete and correct to me. Anything specific you want changed?


call the file custom-compaction.ts, update README.md for the hooks examples dir


bash

cd /Users/badlogic/workspaces/pi-mono && mv packages/coding-agent/examples/hooks/full-compaction.ts packages/coding-agent/examples/hooks/custom-compaction.ts


(no output)


# Hooks Examples

Example hooks for pi-coding-agent.

## Examples

### permission-gate.ts
Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.).

### git-checkpoint.ts
Creates git stash checkpoints at each turn, allowing code restoration when branching.

### protected-paths.ts
Blocks writes to protected paths (.env, .git/, node_modules/).

### file-trigger.ts
Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent.

### confirm-destructive.ts
Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events.

### dirty-repo-guard.ts
Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit.

### auto-commit-on-exit.ts
Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message.

### full-compaction.ts
Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.

## Usage

```bash
# Test directly
pi --hook examples/hooks/permission-gate.ts

# Or copy to hooks directory for persistent use
cp permission-gate.ts ~/.pi/agent/hooks/
```

## Writing Hooks

See [docs/hooks.md](../../docs/hooks.md) for full documentation.

### Key Points

**Hook structure:**
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";

export default function (pi: HookAPI) {
  pi.on("session", async (event, ctx) => {
    // event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" |
    //               "before_branch" | "branch" | "shutdown"
    // event.targetTurnIndex: number (only for before_branch/branch)
    // ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI

    // Cancel before_* actions:
    if (event.reason === "before_clear") {
      return { cancel: true };
    }
    return undefined;
  });

  pi.on("tool_call", async (event, ctx) => {
    // Can block tool execution
    if (dangerous) {
      return { block: true, reason: "Blocked" };
    }
    return undefined;
  });

  pi.on("tool_result", async (event, ctx) => {
    // Can modify result
    return { result: "modified result" };
  });
}
```

**Available events:**
- `session` - lifecycle events with before/after variants (can cancel before_* actions)
- `agent_start` / `agent_end` - per user prompt
- `turn_start` / `turn_end` - per LLM turn
- `tool_call` - before tool execution (can block)
- `tool_result` - after tool execution (can modify)

**UI methods:**
```typescript
const choice = await ctx.ui.select("Title", ["Option A", "Option B"]);
const confirmed = await ctx.ui.confirm("Title", "Are you sure?");
const input = await ctx.ui.input("Title", "placeholder");
ctx.ui.notify("Message", "info"); // or "warning", "error"
```

**Sending messages:**
```typescript
pi.send("Message to inject into conversation");
```


edit

### full-compaction.ts
Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.

### custom-compaction.ts
Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/examples/hooks/README.md. Changed 359 characters to 361 characters.

    ...
 24
 25 ### auto-commit-on-exit.ts
 26 Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message.
 27
-28 ### full-compaction.ts
+28 ### custom-compaction.ts
 29 Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.
 30
 31 ## Usage
 32
    ...


Now update the reference in hooks.md:

bash

cd /Users/badlogic/workspaces/pi-mono && grep -n "full-compaction" packages/coding-agent/docs/hooks.md


302:See [examples/hooks/full-compaction.ts](../examples/hooks/full-compaction.ts) for a complete example.


edit

See [examples/hooks/full-compaction.ts](../examples/hooks/full-compaction.ts) for a complete example.

See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 101 characters to 105 characters.

     ...
 298   }
 299 };
 300 ```
 301
-302 See [examples/hooks/full-compaction.ts](../examples/hooks/full-compaction.ts) for a complete example.
+302 See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.
 303
 304 **After compaction (`compact` event):**
 305 - `event.compactionEntry`: The saved compaction entry
 306 - `event.tokensBefore`: Token count before compaction
     ...


Also update the comment in the file itself:

/**
 * Full Context Compaction Hook
 *
 * Replaces the default compaction behavior with a full summary of the entire context.
 * Instead of keeping the last 20k tokens of conversation turns, this hook:
 * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep)
 * 2. Discards all old turns completely, keeping only the summary
 *
 * This is useful when you want maximum context window space for new work
 * at the cost of losing exact conversation history.
 *
 * Usage:
 *   pi --hook examples/hooks/full-compaction.ts
 */

/**
 * Custom Compaction Hook
 *
 * Replaces the default compaction behavior with a full summary of the entire context.
 * Instead of keeping the last 20k tokens of conversation turns, this hook:
 * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep)
 * 2. Discards all old turns completely, keeping only the summary
 *
 * This is useful when you want maximum context window space for new work
 * at the cost of losing exact conversation history.
 *
 * Usage:
 *   pi --hook examples/hooks/custom-compaction.ts
 */


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/examples/hooks/custom-compaction.ts. Changed 539 characters to 535 characters.

   1 /**
-  2  * Full Context Compaction Hook
+  2  * Custom Compaction Hook
   3  *
   4  * Replaces the default compaction behavior with a full summary of the entire context.
   5  * Instead of keeping the last 20k tokens of conversation turns, this hook:
   6  * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep)
   7  * 2. Discards all old turns completely, keeping only the summary
   8  *
   9  * This is useful when you want maximum context window space for new work
  10  * at the cost of losing exact conversation history.
  11  *
  12  * Usage:
- 13  *   pi --hook examples/hooks/full-compaction.ts
+ 13  *   pi --hook examples/hooks/custom-compaction.ts
  14  */
  15
  16 import { complete } from "@mariozechner/pi-ai";
  17 import { messageTransformer } from "@mariozechner/pi-coding-agent";
     ...


bash

cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 155ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 40ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 16ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit


Done. Renamed `full-compaction.ts` to `custom-compaction.ts` and updated all references in:
- `packages/coding-agent/examples/hooks/README.md`
- `packages/coding-agent/docs/hooks.md`
- The file's own header comment


i want the custom-compaction example to use gemini-3-flash-preview google provider, so we also show how to fetch a different model


/**
 * Custom Compaction Hook
 *
 * Replaces the default compaction behavior with a full summary of the entire context.
 * Instead of keeping the last 20k tokens of conversation turns, this hook:
 * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and the last compaction summary)
 * 2. Discards all old turns completely, keeping only the summary
 *
 * This is useful when you want maximum context window space for new work
 * at the cost of losing exact conversation history.
 *
 * Usage:
 *   pi --hook examples/hooks/custom-compaction.ts
 */

import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } 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;

		const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, model, resolveApiKey, entries } = event;

		// Combine all messages for full summary
		const allMessages = [...messagesToSummarize, ...messagesToKeep];

		ctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, "info");

		// Resolve API key for the model
		const apiKey = await resolveApiKey(model);
		if (!apiKey) {
			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
			return;
		}

		// Transform app messages to pi-ai package format, so they can be passed
		// to the LLM completion function
		const transformedMessages = messageTransformer(allMessages);

		// Include previous summary context if available
		const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";

		// Build messages that ask for a comprehensive summary
		const summaryMessages = [
			...transformedMessages,
			{
				role: "user" as const,
				content: [
					{
						type: "text" as const,
						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}

1. The main goals and objectives discussed
2. Key decisions made and their rationale
3. Important code changes, file modifications, or technical details
4. Current state of any ongoing work
5. Any blockers, issues, or open questions
6. Next steps that were planned or suggested

Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.

Format the summary as structured markdown with clear sections.`,
					},
				],
				timestamp: Date.now(),
			},
		];

		try {
			// Use the same model with resolved API key
			const response = await complete(model, { messages: summaryMessages }, { apiKey, maxTokens: 8192 });

			const summary = response.content
				.filter((c): c is { type: "text"; text: string } => c.type === "text")
				.map((c) => c.text)
				.join("\n");

			if (!summary.trim()) {
				ctx.ui.notify("Compaction summary was empty, using default compaction", "warning");
				return; // Fall back to default compaction
			}

			// Return a compaction entry that discards ALL messages
			// firstKeptEntryIndex points past all current entries
			return {
				compactionEntry: {
					type: "compaction" as const,
					timestamp: new Date().toISOString(),
					summary,
					firstKeptEntryIndex: entries.length,
					tokensBefore,
				},
			};
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			ctx.ui.notify(`Compaction failed: ${message}`, "error");
			// Fall back to default compaction on error
			return;
		}
	});
}


cd /Users/badlogic/workspaces/pi-mono && grep -rn "getModel\|findModel\|google" packages/ai/src/ | head -20


packages/ai/src/providers/google-gemini-cli.ts:3: * Shared implementation for both google-gemini-cli and google-antigravity providers.
packages/ai/src/providers/google-gemini-cli.ts:7:import type { Content, ThinkingConfig, ThinkingLevel } from "@google/genai";
packages/ai/src/providers/google-gemini-cli.ts:22:import { convertMessages, convertTools, mapStopReasonString, mapToolChoice } from "./google-shared.js";
packages/ai/src/providers/google-gemini-cli.ts:43:const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
packages/ai/src/providers/google-gemini-cli.ts:46:	"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
packages/ai/src/providers/google-gemini-cli.ts:58:	"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
packages/ai/src/providers/google-gemini-cli.ts:122:export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
packages/ai/src/providers/google-gemini-cli.ts:123:	model: Model<"google-gemini-cli">,
packages/ai/src/providers/google-gemini-cli.ts:133:			api: "google-gemini-cli" as Api,
packages/ai/src/providers/google-gemini-cli.ts:175:			const isAntigravity = endpoint.includes("sandbox.googleapis.com");
packages/ai/src/providers/google-gemini-cli.ts:417:	model: Model<"google-gemini-cli">,
packages/ai/src/providers/google.ts:7:} from "@google/genai";
packages/ai/src/providers/google.ts:22:import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js";
packages/ai/src/providers/google.ts:36:export const streamGoogle: StreamFunction<"google-generative-ai"> = (
packages/ai/src/providers/google.ts:37:	model: Model<"google-generative-ai">,
packages/ai/src/providers/google.ts:47:			api: "google-generative-ai" as Api,
packages/ai/src/providers/google.ts:65:			const googleStream = await client.models.generateContentStream(params);
packages/ai/src/providers/google.ts:71:			for await (const chunk of googleStream) {
packages/ai/src/providers/google.ts:250:function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI {
packages/ai/src/providers/google.ts:276:	model: Model<"google-generative-ai">,


cd /Users/badlogic/workspaces/pi-mono && grep -rn "getAvailableModels\|createModel\|models\[" packages/coding-agent/src/core/sdk.ts | head -10


packages/coding-agent/src/core/sdk.ts:46:	getAvailableModels,
packages/coding-agent/src/core/sdk.ts:226:	const { models, error } = await getAvailableModels(agentDir);


cd /Users/badlogic/workspaces/pi-mono && grep -n "export.*getAvailableModels\|export.*Model" packages/ai/src/index.ts | head -10


(no output)


cd /Users/badlogic/workspaces/pi-mono && grep -rn "gemini.*flash" packages/ai/src/ | head -10


packages/ai/src/providers/google-gemini-cli.ts:29:	 * - Gemini 3 models (gemini-3-pro-*, gemini-3-flash-*): use `level` instead
packages/ai/src/utils/oauth/google-gemini-cli.ts:3: * Standard Gemini models only (gemini-2.0-flash, gemini-2.5-*)
packages/ai/src/stream.ts:290:	// Covers gemini-3-flash, gemini-3-flash-preview, and possible other prefixed ids in the future
packages/ai/src/models.generated.ts:367:		"gemini-3-flash-preview": {
packages/ai/src/models.generated.ts:368:			id: "gemini-3-flash-preview",
packages/ai/src/models.generated.ts:384:		"gemini-2.5-flash-preview-05-20": {
packages/ai/src/models.generated.ts:385:			id: "gemini-2.5-flash-preview-05-20",
packages/ai/src/models.generated.ts:401:		"gemini-flash-lite-latest": {
packages/ai/src/models.generated.ts:402:			id: "gemini-flash-lite-latest",
packages/ai/src/models.generated.ts:435:		"gemini-2.5-flash": {


cd /Users/badlogic/workspaces/pi-mono && grep -rn "google.*provider\|provider.*google" packages/ai/src/models.generated.ts | head -5


packages/ai/src/models.generated.ts:371:			provider: "google",
packages/ai/src/models.generated.ts:388:			provider: "google",
packages/ai/src/models.generated.ts:405:			provider: "google",
packages/ai/src/models.generated.ts:422:			provider: "google",
packages/ai/src/models.generated.ts:439:			provider: "google",


cd /Users/badlogic/workspaces/pi-mono && grep -rn "getModel\|findModelById" packages/ai/src/*.ts packages/coding-agent/src/core/sdk.ts | head -20


packages/ai/src/models.ts:20:export function getModel<TProvider extends KnownProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
packages/ai/src/models.ts:31:export function getModels<TProvider extends KnownProvider>(


type ModelApi<
	TProvider extends KnownProvider,
	TModelId extends keyof (typeof MODELS)[TProvider],
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;

export function getModel<TProvider extends KnownProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
	provider: TProvider,
	modelId: TModelId,
): Model<ModelApi<TProvider, TModelId>> {
	return modelRegistry.get(provider)?.get(modelId as string) as Model<ModelApi<TProvider, TModelId>>;
}

export function getProviders(): KnownProvider[] {
	return Array.from(modelRegistry.keys()) as KnownProvider[];
}

export function getModels<TProvider extends KnownProvider>(
	provider: TProvider,
): Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
	const models = modelRegistry.get(provider);
	return models ? (Array.from(models.values()) as Model<ModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[]) : [];
}

export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
	usage.cost.input = (model.cost.input / 1000000) * usage.input;
	usage.cost.output = (model.cost.output / 1000000) * usage.output;
	usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
	usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite;
	usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
	return usage.cost;

[13 more lines in file. Use offset=45 to continue]


cd /Users/badlogic/workspaces/pi-mono && grep -n "export.*getModel" packages/ai/src/index.ts


(no output)

Command exited with code 1


cd /Users/badlogic/workspaces/pi-mono && grep -n "getModel" packages/ai/src/index.ts


cd /Users/badlogic/workspaces/pi-mono && cat packages/ai/src/index.ts | head -40


export * from "./agent/index.js";
export * from "./models.js";
export * from "./providers/anthropic.js";
export * from "./providers/google.js";
export * from "./providers/google-gemini-cli.js";
export * from "./providers/openai-completions.js";
export * from "./providers/openai-responses.js";
export * from "./stream.js";
export * from "./types.js";
export * from "./utils/oauth/index.js";
export * from "./utils/overflow.js";
export * from "./utils/typebox-helpers.js";
export * from "./utils/validation.js";


So `getModel` is exported via `export * from "./models.js"`. Let me update the example:

edit

/**
 * Custom Compaction Hook
 *
 * Replaces the default compaction behavior with a full summary of the entire context.
 * Instead of keeping the last 20k tokens of conversation turns, this hook:
 * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and the last compaction summary)
 * 2. Discards all old turns completely, keeping only the summary
 *
 * This is useful when you want maximum context window space for new work
 * at the cost of losing exact conversation history.
 *
 * Usage:
 *   pi --hook examples/hooks/custom-compaction.ts
 */

import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } 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;

		const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, model, resolveApiKey, entries } = event;

		// Combine all messages for full summary
		const allMessages = [...messagesToSummarize, ...messagesToKeep];

		ctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, "info");

		// Resolve API key for the model
		const apiKey = await resolveApiKey(model);
		if (!apiKey) {
			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
			return;
		}

		// Transform app messages to pi-ai package format, so they can be passed
		// to the LLM completion function
		const transformedMessages = messageTransformer(allMessages);

{"path": "/Users/badlogic/workspaces/pi-mono/packages/coding-agent/examples/hooks/custom-compaction.ts", "oldText": "/**\n * Custom Compaction Hook\n *\n * Replaces the default compaction behavior with a full summary of the entire context.\n * Instead of keeping the last 20k tokens of conversation turns, this hook:\n * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and the last compaction summary)\n * 2. Discards all old turns completely, keeping only the summary\n *\n * This is useful when you want maximum context window space for new work\n * at the cost of losing exact conversation history.\n *\n * Usage:\n *   pi --hook examples/hooks/custom-compaction.ts\n */\n\nimport { complete } from \"@mariozechner/pi-ai\";\nimport { messageTransformer } from \"@mariozechner/pi-coding-agent\";\nimport type { HookAPI } from \"@mariozechner/pi-coding-agent/hooks\";\n\nexport default function (pi: HookAPI) {\n\tpi.on(\"session\", async (event, ctx) => {\n\t\tif (event.reason !== \"before_compact\") return;\n\n\t\tconst { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, model, resolveApiKey, entries } = event;\n\n\t\t// Combine all messages for full summary\n\t\tconst allMessages = [...messagesToSummarize, ...messagesToKeep];\n\n\t\tctx.ui.notify(`Full compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens)...`, \"info\");\n\n\t\t// Resolve API key for the model\n\t\tconst apiKey = await resolveApiKey(model);\n\t\tif (!apiKey) {\n\t\t\tctx.ui.notify(`No API key for ${model.provider}, using default compaction`, \"warning\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Transform app messages to pi-ai package format, so they can be passed\n\t\t// to the LLM completion function\n\t\tconst transformedMessages = messageTransformer(allMessages);", "newText": "/**\n * Custom Compaction Hook\n *\n *


well, i think we need to use model-config.ts findModel, so we also get custom models, no?


You're right. Let me check `findModel`:

bash

cd /Users/badlogic/workspaces/pi-mono && grep -rn "findModel" packages/coding-agent/src/


packages/coding-agent/src/core/model-resolver.ts:9:import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
packages/coding-agent/src/core/model-resolver.ts:230:		const { model: found, error } = findModel(cliProvider, cliModel);
packages/coding-agent/src/core/model-resolver.ts:255:		const { model: found, error } = findModel(defaultProvider, defaultModelId);
packages/coding-agent/src/core/model-resolver.ts:306:	const { model: restoredModel, error } = findModel(savedProvider, savedModelId);
packages/coding-agent/src/core/sdk.ts:44:	findModel as findModelInternal,
packages/coding-agent/src/core/sdk.ts:237:export function findModel(
packages/coding-agent/src/core/sdk.ts:242:	const { model, error } = findModelInternal(provider, modelId, agentDir);
packages/coding-agent/src/core/sdk.ts:457: *   model: findModel('anthropic', 'claude-sonnet-4-20250514'),
packages/coding-agent/src/core/sdk.ts:508:		const restoredModel = findModel(existingSession.model.provider, existingSession.model.modelId);
packages/coding-agent/src/core/sdk.ts:522:			const settingsModel = findModel(defaultProvider, defaultModelId);
packages/coding-agent/src/core/model-config.ts:386:export function findModel(
packages/coding-agent/src/main.ts:21:import { findModel } from "./core/model-config.js";
packages/coding-agent/src/main.ts:190:		const { model, error } = findModel(parsed.provider, parsed.model);
packages/coding-agent/src/index.ts:76:export { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js";
packages/coding-agent/src/index.ts:116:	findModel as findModelByProviderAndId,


}

/**
 * Find a specific model by provider and ID
 * Returns { model, error } - either model or error message
 */
export function findModel(
	provider: string,
	modelId: string,
	agentDir: string = getAgentDir(),
): { model: Model<Api> | null; error: string | null } {
	const { models: allModels, error } = loadAndMergeModels(agentDir);

	if (error) {
		return { model: null, error };
	}

	const model = allModels.find((m) => m.provider === provider && m.id === modelId) || null;
	return { model, error: null };
}

/**
 * Mapping from model provider to OAuth provider ID.
 * Only providers that support OAuth are listed here.
 */
const providerToOAuthProvider: Record<string, OAuthProvider> = {
	anthropic: "anthropic",
	"github-copilot": "github-copilot",
	"google-gemini-cli": "google-gemini-cli",
	"google-antigravity": "google-antigravity",

[46 more lines in file. Use offset=410 to continue]


Good, `findModel` is exported from the main package. Now let me update the example:


write

/**
 * Custom Compaction Hook
 *
 * Replaces the default compaction behavior with a full summary of the entire context.
 * Instead of keeping the last 20k tokens of conversation turns, this hook:
 * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and previousSummary)
 * 2. Discards all old turns completely, keeping only the summary
 *
 * This example also demonstrates using a different model (Gemini Flash) for summarization,
 * which can be cheaper/faster than the main conversation model.
 *
 * Usage:
 *   pi --hook examples/hooks/custom-compaction.ts
 */

import { complete } from "@mariozechner/pi-ai";
import { findModel, messageTransformer } 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;

		const { messagesToSummarize, messagesToKeep, previousSummary, tokensBefore, resolveApiKey, entries } = event;

		// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
		// findModel searches both built-in models and custom models from models.json
		const { model, error } = findModel("google", "gemini-2.5-flash");
		if (error || !model) {
			ctx.ui.notify(`Could not find Gemini Flash model: ${error}, using default compaction`, "warning");
			return;
		}

		// Resolve API key for the summarization model
		const apiKey = await resolveApiKey(model);
		if (!apiKey) {
			ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
			return;
		}

		// Combine all messages for full summary
		const allMessages = [...messagesToSummarize, ...messagesToKeep];

		ctx.ui.notify(
			`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,
			"info",
		);

		// Transform app messages to pi-ai package format
		const transformedMessages = messageTransformer(allMessages);

		// Include previous summary context if available
		const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";

		// Build messages that ask for a comprehensive summary
		const summaryMessages = [
			...transformedMessages,
			{
				role: "user" as const,
				content: [
					{
						type: "text" as const,
						text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}

1. The main goals and objectives discussed
2. Key decisions made and their rationale
3. Important code changes, file modifications, or technical details
4. Current state of any ongoing work
5. Any blockers, issues, or open questions
6. Next steps that were planned or suggested

Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.

Format the summary as structured markdown with clear sections.`,
					},
				],
				timestamp: Date.now(),
			},
		];

		try {
			const response = await complete(model, { messages: summaryMessages }, { apiKey, maxTokens: 8192 });

			const summary = response.content
				.filter((c): c is { type: "text"; text: string } => c.type === "text")
				.map((c) => c.text)
				.join("\n");

			if (!summary.trim()) {
				ctx.ui.notify("Compaction summary was empty, using default compaction", "warning");
				return;
			}

			// Return a compaction entry that discards ALL messages
			// firstKeptEntryIndex points past all current entries
			return {
				compactionEntry: {
					type: "compaction" as const,
					timestamp: new Date().toISOString(),
					summary,
					firstKeptEntryIndex: entries.length,
					tokensBefore,
				},
			};
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			ctx.ui.notify(`Compaction failed: ${message}`, "error");
			// Fall back to default compaction on error
			return;
		}
	});
}


Successfully wrote 4004 bytes to /Users/badlogic/workspaces/pi-mono/packages/coding-agent/examples/hooks/custom-compaction.ts


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 161ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 39ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 16ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 4ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit


Done. Updated the example to:
1. Use `findModel("google", "gemini-2.5-flash")` to get a different model for summarization
2. Added comment explaining `findModel` searches both built-in and custom models
3. Updated the header comment to mention using a different model
4. Shows using `resolveApiKey(model)` with the different model


can we please improve the doc string for findModel, it should specify what the sources for models are


}

/**
 * Find a specific model by provider and ID
 * Returns { model, error } - either model or error message
 */
export function findModel(
	provider: string,
	modelId: string,
	agentDir: string = getAgentDir(),
): { model: Model<Api> | null; error: string | null } {
	const { models: allModels, error } = loadAndMergeModels(agentDir);

	if (error) {
		return { model: null, error };
	}

	const model = allModels.find((m) => m.provider === provider && m.id === modelId) || null;
	return { model, error: null };
}

[56 more lines in file. Use offset=400 to continue]


edit

/**
 * Find a specific model by provider and ID
 * Returns { model, error } - either model or error message
 */
export function findModel(

/**
 * Find a specific model by provider and ID.
 *
 * Searches models from:
 * 1. Built-in models from @mariozechner/pi-ai
 * 2. Custom models defined in ~/.pi/agent/models.json
 *
 * Returns { model, error } - either the model or an error message.
 */
export function findModel(


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/model-config.ts. Changed 138 characters to 280 characters.

     ...
 379 	return { models: availableModels, error: null };
 380 }
 381
 382 /**
-383  * Find a specific model by provider and ID
-384  * Returns { model, error } - either model or error message
+383  * Find a specific model by provider and ID.
+384  *
+385  * Searches models from:
+386  * 1. Built-in models from @mariozechner/pi-ai
+387  * 2. Custom models defined in ~/.pi/agent/models.json
+388  *
+389  * Returns { model, error } - either the model or an error message.
 385  */
 386 export function findModel(
 387 	provider: string,
 388 	modelId: string,
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 154ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 39ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 17ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit


Done. Updated the docstring for `findModel` to specify the model sources:
1. Built-in models from @mariozechner/pi-ai
2. Custom models defined in ~/.pi/agent/models.json


the common patterns section sucks, remove it and just keep the See examples/hooks shit


| `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
| `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |

**Common patterns:**

```typescript
// Get all messages since last compaction
const allMessages = [...event.messagesToSummarize, ...event.messagesToKeep];

// Default behavior: summarize old, keep recent
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Your custom summary...",
    firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
    tokensBefore: event.tokensBefore,
  }
};

// Full compaction: summarize everything, keep nothing
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Complete summary of all work...",
    firstKeptEntryIndex: event.entries.length, // discard all messages
    tokensBefore: event.tokensBefore,
  }
};
```

See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.

**After compaction (`compact` event):**
- `event.compactionEntry`: The saved compaction entry
- `event.tokensBefore`: Token count before compaction
- `event.fromHook`: Whether the compaction entry was provided by a hook

### agent_start / agent_end

Fired once per user prompt.

```typescript
pi.on("agent_start", async (event, ctx) => {});

pi.on("agent_end", async (event, ctx) => {
  // event.messages: AppMessage[] - new messages from this prompt
});
```

[623 more lines in file. Use offset=320 to continue]


**Common patterns:**

```typescript
// Get all messages since last compaction
const allMessages = [...event.messagesToSummarize, ...event.messagesToKeep];

// Default behavior: summarize old, keep recent
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Your custom summary...",
    firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
    tokensBefore: event.tokensBefore,
  }
};

// Full compaction: summarize everything, keep nothing
return {
  compactionEntry: {
    type: "compaction",
    timestamp: new Date().toISOString(),
    summary: "Complete summary of all work...",
    firstKeptEntryIndex: event.entries.length, // discard all messages
    tokensBefore: event.tokensBefore,
  }
};
```

See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.


Successfully replaced text in /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/hooks.md. Changed 890 characters to 105 characters.

     ...
 269 | `model` | Model to use for summarization. |
 270 | `resolveApiKey` | Function to resolve API key for any model: `await resolveApiKey(model)` |
 271 | `customInstructions` | Optional focus for summary (from `/compact <instructions>`). |
 272
-273 **Common patterns:**
-274
-275 ```typescript
-276 // Get all messages since last compaction
-277 const allMessages = [...event.messagesToSummarize, ...event.messagesToKeep];
-278
-279 // Default behavior: summarize old, keep recent
-280 return {
-281   compactionEntry: {
-282     type: "compaction",
-283     timestamp: new Date().toISOString(),
-284     summary: "Your custom summary...",
-285     firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, // keep recent turns
-286     tokensBefore: event.tokensBefore,
-287   }
-288 };
-289
-290 // Full compaction: summarize everything, keep nothing
-291 return {
-292   compactionEntry: {
-293     type: "compaction",
-294     timestamp: new Date().toISOString(),
-295     summary: "Complete summary of all work...",
-296     firstKeptEntryIndex: event.entries.length, // discard all messages
-297     tokensBefore: event.tokensBefore,
-298   }
-299 };
-300 ```
-301
 302 See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example.
 303
 304 **After compaction (`compact` event):**
 305 - `event.compactionEntry`: The saved compaction entry
     ...


> pi-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 286 files in 159ms. No fixes applied.

> @mariozechner/pi-agent-core@0.27.5 check
> tsgo --noEmit

> @mariozechner/pi-ai@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 44 files in 39ms. No fixes applied.

> @mariozechner/pi-coding-agent@0.27.5 check
> tsgo --noEmit && tsgo -p tsconfig.examples.json

> @mariozechner/pi-mom@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 16 files in 21ms. No fixes applied.

> @mariozechner/pi@0.27.5 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/pi-proxy@0.27.5 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/pi-tui@0.27.5 check
> biome check --write . && tsgo --noEmit

Checked 27 files in 25ms. No fixes applied.

> @mariozechner/pi-web-ui@0.27.5 check
> tsgo --noEmit && cd example && tsgo --noEmit

> pi-web-ui-example@1.15.5 check
> tsgo --noEmit