# Technical Documentation: modem-dev/hunk

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/modem-dev/hunk)

### Overview (`README.md`)

# hunk

Hunk is an interactive terminal diff viewer for reviewing code changes, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs).

**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/)

[](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main)
[](https://github.com/modem-dev/hunk/releases)
[](LICENSE)
[](https://discord.gg/WZFjaP6Gt8)

- multi-file review stream with sidebar navigation
- inline AI and agent annotations beside the code
- split, unified, and responsive auto layouts
- watch mode for auto-reloading file and Git-backed reviews
- keyboard, mouse, pager, and Git difftool support

<table>
 <tr>
   <td width="60%" align="center">
     <img width="845" alt="image" src="https://raw.githubusercontent.com/modem-dev/hunk/refs/heads/main/website/public/docs/images/review-stream.webp" />
     <br />
     <sub>Split view with sidebar and inline AI notes</sub>
   </td>
   <td width="40%" align="center">
     <img width="507"alt="image" src="https://raw.githubusercontent.com/modem-dev/hunk/refs/heads/main/website/public/docs/images/agent-comments.webp" />
     <br />
     <sub>Unified view and mouse-selectable menus</sub>
   </td>
 </tr>
</table>

## Install

On macOS and Linux, install the standalone binary:

```bash
curl -fsSL https://hunk.dev/install.sh | sh
```

Windows users can install with npm or mise. Other installation methods are also available:

```bash
npm i hunkdiff                    # macOS, Linux, or Windows; requires Node.js 22+
brew install hunk                    # macOS or Linux
mise use -g hunk                     # macOS, Linux, or Windows
```

> [!NOTE]
> If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`.

Windows requires mise 2026.8.6 or newer. Nix users can use the `default` package exported in `flake.nix`; see [the Nix guide](https://github.com/modem-dev/hunk/blob/main/nix/README.md) for details. Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise.

Requirements:

- macOS (arm64, x64), Linux (arm64, x64; glibc), or Windows (x64); prebuilt binaries are not published for Windows on arm64 or musl-based Linux such as Alpine
- On x86-64, a CPU with SSE4.2 (Intel Nehalem 2008+, AMD Bulldozer 2011+); arm64 has no CPU feature floor
- Node.js 22+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary that does not require Node.js
- Git recommended for most workflows

### Update Hunk

For npm, Homebrew, and default install-script installs (Hunk 0.20+):

```bash
hunk update          # install the newest release
hunk update --check  # check without installing
hunk update 0.20.0   # select an exact npm or default install-script release
```

On an older release, update once with the installer or package manager that installed Hunk, then use `hunk update` going forward. Custom `HUNK_INSTALL_DIR` installs must re-run the installer with the same directory; mise, Nix, and source installs use their owning tools instead.

## Quick start

```bash
hunk           # show help
hunk --version # print the installed version
```

### Working with Git

Use familiar Git commands to open changes in Hunk:

```bash
hunk diff                      # review current repo changes, including untracked files
hunk diff --watch              # auto-reload as the working tree changes
hunk show                      # review the latest commit
hunk show HEAD~1               # review an earlier commit
hunk log                       # browse history on a terminal; print when redirected
hunk log --static              # force static output, paging when needed
hunk status                    # view workspace status and sibling worktrees (Git)
hunk status --static           # print one read-only workspace snapshot
hunk status --json             # emit one versioned workspace snapshot
```

`hunk status` is read-only: it does not fetch or change the repository. See [workspace status](docs/status.md) for details.

`hunk log` opens an interactive history browser in a terminal and prints text when piped or redirected. Use `--oneline` for compact output. In the browser, enable **Graph view** from the View menu to see commit branches, or press `v` to select a commit range. Quit a commit review to return to history.

### Working with GitHub

Hunk bundles `hunk gh`, so pull requests, commits, and comparisons open directly without the
GitHub CLI or a separate extension:

```bash
hunk gh pr                         # infer the open PR for the current branch
hunk gh pr 123                     # infer the repository from origin
hunk gh pr 'modem-dev/hunk#123'    # quote this form for shells that treat # as a comment
hunk gh commit a1b2c3d --repo modem-dev/hunk
hunk gh compare main...feature --repo modem-dev/hunk
```

Public repositories work anonymously. Set `GH_TOKEN`, or `GITHUB_TOKEN` when it is absent, for
private repositories and higher API limits. GitHub Enterprise is not currently supported.

### Working with Jujutsu and Sapling

Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config).

### Viewing complete files and browsing directories

```bash
hunk open                 # browse the current directory
hunk open README.md       # view a complete, syntax-highlighted document
hunk open path/to/project # browse another directory without requiring a repository
```

Use arrows and Enter to navigate, Tab to switch focus, `i` to show hidden/Git-ignored entries, and `e` to edit the displayed file in `$EDITOR`. See [the open workflow](docs/open.md) for details and file limits.

### Working with raw files and patches

```bash
hunk diff --files before.ts after.ts        # compare two files directly
hunk diff --files before.ts after.ts --watch # auto-reload when either file changes
git diff --no-color | hunk patch -          # review a patch from stdin
```

### Working with agents

1. Install the review skill once: `npx skills add modem-dev/hunk -g --skill hunk-review`.
2. Open Hunk in another terminal with `hunk diff` or `hunk show`.
3. Ask your agent to use the hunk-review skill against the live Hunk session.
4. Press `a` in Hunk to show agent notes; they start hidden.

If your agent has no skill system, ask it to run `hunk skill show` and follow the output.

See [Review with an agent](https://hunk.dev/docs/agents/review-with-an-agent/) for live sessions, `--agent-context`, and experimental rich notes.

## Feature comparison

| Capability                           | [hunk](https://github.com/modem-dev/hunk) | [lumen](https://github.com/jnsahaj/lumen) | [difftastic](https://github.com/Wilfred/difftastic) | [delta](https://github.com/dandavison/delta) | [diff-so-fancy](https://github.com/so-fancy/diff-so-fancy) | [diff](https://www.gnu.org/software/diffutils/) |
| ------------------------------------ | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------- |
| Review-first interactive UI          | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Multi-file review stream + sidebar   | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Inline agent / AI annotations        | ✅                                        | ❌                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Responsive auto split/unified layout | ✅                                        | ❌                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Mouse support inside the viewer      | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Runtime view toggles                 | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Syntax highlighting                  | ✅                                        | ✅                                        | ✅                                                  | ✅                                           | ❌                                                         | ❌                                              |
| Structural diffing                   | ❌                                        | ❌                                        | ✅                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Pager-compatible mode                | ✅                                        | ❌                                        | ✅                                                  | ✅                                           | ✅                                                         | ✅                                              |

## Advanced

### Config

You can persist preferences to a config file:

- `$XDG_CONFIG_HOME/hunk/config.toml` (default `~/.config/hunk/config.toml`)
- `.hunk/config.toml` in a repository

Persistent state uses `$XDG_STATE_HOME/hunk/state.json` when the variable is
absolute, or `~/.local/state/hunk/state.json` otherwise. Hunk preserves existing
state by copying the old config-directory file on first use, keeping the original
and falling back to it if migration fails. Global extension paths are unchanged.

For example:

```toml
theme = "github-dark-default"
mode = "auto"               # auto, split, unified
auto_split_min_width = 160  # minimum available diff columns for split in auto mode
line_numbers = true
wrap_lines = false
```

See the [configuration guide](https://hunk.dev/docs/configure/configuration/) for command overrides and examples, the [full reference](https://hunk.dev/docs/reference/config/) for every option, or the [theme guide](https://hunk.dev/docs/configure/themes/) to customize colors.

### Keybindings

Remap shortcuts with a `[keybindings]` table in your user config. See [the keybinding guide](https://hunk.dev/docs/configure/keybindings/) for available commands, default keys, and examples.

### Git integration

Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically:

> [!NOTE]
> Untracked files are auto-included only for Hunk's own `hunk diff` working-tree loader. If you open `git diff` through `hunk pager`, Git still decides the patch contents, so untracked files will not appear there.

```bash
git config --global core.pager "hunk pager"
```

Or in your Git config:

```ini
[core]
    pager = hunk pager
```

If you want to keep Git's default pager and add opt-in aliases instead:

```bash
git config --global alias.hdiff "-c core.pager=\"hunk pager\" diff"
git config --global alias.hshow "-c core.pager=\"hunk pager\" show"
```

### Jujutsu pager integration

To use Hunk as jj's pager, run `jj config edit --user` and update:

```toml
[ui]
pager = ["hunk", "pager"]
diff-formatter = ":git"
```

### Sapling pager integration

To use Hunk as Sapling's pager, run `sl config -u` and update:

```ini
[pager]
pager = hunk pager
```

### Extensions (experimental)

The extension API is experimental and may change in breaking ways between
minor releases while it stabilizes; breaking changes are called out in
release notes.

Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a
repository's `.hunk/extensions/` (after you explicitly trust that repository),
and from `--extension <path>` for development. `--no-extensions` turns those off
for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded.

Extensions can add commands, themes, VCS backends, custom sidebars, and more. For example, filter lockfiles out of a review:

```ts
// ~/.config/hunk/extensions/collapse-lockfiles.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.transformChangeset((changeset, ctx) => {
    const files = changeset.files.filter((file) => !file.path.endsWith(".lock"));
    ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`);
    return { ...changeset, files };
  });
}
```

Extensions shared as git repositories install straight from their host, and a
`hunk-extension` GitHub topic marks community ones:

```bash
hunk extension install acme/hunk-word-diff@v1.2.0   # or git:host/path, a URL, a local path
hunk extension list                                 # then update [name] / remove <name>
```

Browse community extensions at
[github.com/topics/hunk-extension](https://github.com/topics/hunk-extension);
publish yours by pushing the extension to a repository root and adding that
topic.

See [the extension guide](https://hunk.dev/docs/extend/extensions/) for the API, configuration, and publishing instructions.
Installable examples include
[review triage](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-triage),
[review snapshot export](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export), an optional
[rendered Markdown file view](https://github.com/modem-dev/hunk/tree/main/examples/extensions/rendered-markdown), and a
[Vim navigation mode](https://github.com/modem-dev/hunk/tree/main/examples/extensions/vim-navigation) built from public semantic commands.

### OpenTUI component

Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app.

See [the OpenTUI component guide](https://hunk.dev/docs/reference/opentui-components/) for install, API, and runnable examples.

## Examples

Ready-to-run demo diffs live in [the examples directory](https://github.com/modem-dev/hunk/tree/main/examples).

Each example includes the exact command to run from the repository root.

## Contributing

💬 _Chat with users/contributors on the [Modem Discord server](https://discord.gg/WZFjaP6Gt8)_

See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and contribution guidelines.

## Sponsor

Sponsored by [Modem](https://modem.dev?utm_source=github&utm_medium=oss&utm_campaign=oss_hunk&utm_content=readme_footer).

<a href="https://modem.dev?utm_source=github&utm_medium=oss&utm_campaign=oss_hunk&utm_content=readme_footer">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://modem.dev/images/logo/svg/modem-combined-white.svg">
    <source media="(prefers-color-scheme: light)" srcset="https://modem.dev/images/logo/svg/modem-combined-black.svg">
    <img src="https://modem.dev/images/logo/svg/modem-combined-black.svg" alt="Modem" width="220">
  </picture>
</a>

## License

[MIT](https://github.com/modem-dev/hunk/blob/main/LICENSE)

---

### Overview (`.changeset/README.md`)

# Changesets

Hunk uses [Changesets](https://github.com/changesets/changesets) for release-note fragments and npm version preparation.

For user-visible changes, add a changeset instead of editing `CHANGELOG.md` directly:

```bash
bun run changeset
```

Select `hunkdiff` and choose the semver bump that matches the shipped CLI/package change:

- `patch` for fixes and small behavior changes
- `minor` for new user-facing features
- `major` for breaking changes

The private root workspace lists `packages/*`, and Changesets discovers the published `hunkdiff`
package at `packages/hunk/package.json`. Private implementation workspaces remain ignored and are
never published.

For maintenance-only PRs that should not appear in release notes, create an empty changeset:

```bash
bun run changeset -- --empty
```

Release prep runs:

```bash
bun run release:version
```

`CHANGELOG.md` at the repository root is the canonical changelog consumed by the website and
release tooling. The version wrapper temporarily stages that history beside the package for
Changesets, then copies the generated release entry back to the root and removes the temporary
package changelog. It also bumps `packages/hunk/package.json` and refreshes `bun.lock` so the
release commit records the new workspace version.

After the tag release publishes npm packages and GitHub release assets, verify Homebrew through `Homebrew/homebrew-core`. Hunk is on Homebrew's Autobump list, so do not open manual simple version-bump PRs. Wait for the automated `hunk <version>` PR, confirm it merges, then verify `brew install hunk` resolves to the released version. Use `brew bump-formula-pr hunk --version <version>` only if Homebrew maintainers ask for a manual bump or Autobump stalls unexpectedly.

---

### Overview (`benchmarks/README.md`)

# Benchmarks

Benchmark scripts, shared fixtures, and local result artifacts live here. These benchmarks are local diagnostics for Hunk's core promise: fast loading, fast first render, fast navigation, and predictable memory use on large diffs.

## Running locally

Run the default local benchmark suite with one JSON result file:

```bash
bun run bench -- --samples 3 --out benchmarks/results/local.json
```

Run optional competitor comparisons when the tools are installed:

```bash
bun run bench -- --samples 3 --include-competitors --out benchmarks/results/local-with-competitors.json
```

Include the opt-in huge fixture tier (~1k files / 300k+ diff lines plus one ~50k-line file). A single huge sample can take minutes on the unoptimized hot path, so it is excluded from the default suite; enable it with the flag or `HUNK_BENCH_INCLUDE_HUGE=1`:

```bash
bun run bench -- --samples 1 --include-huge --out benchmarks/results/local-with-huge.json
```

Generate the committed release benchmark snapshot during release prep:

```bash
bun run bench:release
bun run bench:release:compare
```

Run focused scripts while iterating:

```bash
bun run bench:startup-first-frame
bun run bench:bootstrap-load
bun run bench:working-tree-load
bun run bench:changeset-parse
bun run bench:render-layout
bun run bench:highlight-prefetch
bun run bench:highlight-worker-cache
bun run bench:highlight-cache-layers
bun run bench:large-stream
bun run bench:interaction-latency
bun run bench:non-ascii-stream
bun run bench:wrapped-cjk
bun run bench:terminal-width
bun run bench:huge-stream
bun run bench:large-stream-profile
bun run bench:memory
bun run bench:geometry-memory
bun run bench:navigation-memory
bun run bench:resize-memory
bun run bench:competitors
```

## Scripts

- `startup-first-frame.ts` — launches `hunk diff` in a real PTY on a small committed repo with a dirty working tree and measures time from process spawn to the first painted review frame. It is the only metric that includes Bun boot, CLI and config resolution, git bootstrap, and the OpenTUI import with its native-library load; in-process first-frame metrics start after the renderer is already imported. It answers the terminal background probe like a real terminal would. The same launch reports `startup_first_color_ms` (spawn to the first keyword-colored cell) and `startup_post_paint_key_max_ms` (the slowest answer among page-down keys sent from first paint through the next 400ms, which is where main-thread highlighting shows up as blocked input). Set `HUNK_BENCHMARK_EXECUTABLE` to measure a compiled binary instead of the source entrypoint.
- `bootstrap-load.ts` — measures bootstrap and git-loader cost on a synthetic large repo, including file-pair bootstrap.
- `working-tree-load.ts` — measures git working-tree loads across small, medium, large, many-untracked, and few-large-untracked repos.
- `changeset-parse.ts` — measures patch normalization, Pierre parsing, patch chunking, and normalized `DiffFile` construction for many-small-files, balanced, and large-single-file patches.
- `render-layout.ts` — measures pure split/unified row building, section geometry, and review-plan construction for many-small-files, balanced, and large-single-file streams.
- `highlight-prefetch.ts` — measures selected-file highlight startup and adjacent prefetch readiness.
- `worker-highlight-cache.ts` — measures a cold worker highlight against an immediate compact-result cache hit.
- `highlight-cache-layers.ts` — measures a resident terminal-cache hit against a worker-cache revisit after the terminal cache evicts the diff.
- `large-stream.ts` — measures large split-stream first-frame and scroll cost.
- `interaction-latency.ts` — measures per-press `]` hunk-navigation latency and per-scroll-tick latency (median + p95) on the large stream, plus RSS/heap ceilings after first frame and after navigation (the default-suite slice of `memory.ts`). Interaction timing starts after render-driven syntax highlighting reaches idle so Bun scheduler changes cannot move startup work into the first input sample.
- `non-ascii-stream.ts` — measures first-frame and per-scroll-tick latency on a stream whose diff content embeds CJK, emoji, and box-drawing characters, exercising the string-width path on content rather than chrome glyphs. Its scroll timing uses the same highlight-idle boundary.
- `wrapped-cjk.ts` — reproduces issue #579 with 518 wrapped Japanese Markdown lines plus one pathological long logical line, isolates each mount with a full GC while including renderer setup in first-frame latency, and measures immediate/coalesced frames from a real wheel burst.
- `terminal-width.ts` — measures scalar-heavy CJK and emoji width calls plus cached complex-cluster measurements against equivalent `string-width` reference paths, verifying identical width checksums.
- `huge-stream.ts` — opt-in huge tier (`--include-huge` or `HUNK_BENCH_INCLUDE_HUGE=1`): cold first frame, scroll-tick and hunk-navigation latency, and memory ceilings on ~1k files / 300k+ diff lines plus one giant ~50k-line file.
- `large-stream-profile.ts` — optional local profiler for the main pure planning stages behind the large split-stream benchmark.
- `memory.ts` — optional local RSS/heap profiler after fixture loading, planning, first frame, and next-hunk navigation.
- `geometry-memory.ts` — optional local retained-memory profiler for all-files section geometry, including JSC-native heap metrics, navigation projection, and giant-file first-copy latency. Copy-content heap growth is measured from the post-navigation sample; ordinary navigation should not materialize lazy copy-selection rows.
- `navigation-memory.ts` — optional local retained-memory profiler for repeated hunk navigation through a mounted review stream.
- `resize-memory.ts` — optional local retained-memory profiler for repeated terminal-width changes through a mounted review stream; this targets geometry-cache retention across resize variants.
- `competitors.ts` — optional local informational comparisons against `git diff --no-ext-diff`, `delta`, `difftastic`, and `diff-so-fancy` when installed.
- `large-stream-fixture.ts` and `lib/fixtures.ts` — shared deterministic synthetic fixtures.

## Output format

Each script prints `METRIC name=value` lines. `benchmarks/run.ts` repeats scripts, aggregates samples, prints a readable summary, and can write JSON:

```json
{
  "version": 1,
  "samplesPerBenchmark": 3,
  "results": [
    {
      "name": "large-stream/cold_first_frame_ms",
      "unit": "ms",
      "samples": [61.2, 60.8, 62.1],
      "median": 61.2,
      "p75": 62.1,
      "p95": 62.1,
      "threshold": {
        "maxRegressionRatio": 1.15,
        "minAbsoluteRegression": 5
      },
      "comparable": true
    }
  ]
}
```

## Notes

- These benchmarks are intentionally local-only for now. They are useful diagnostics, but CI proved too noisy and slow for PR gating.
- The default local suite excludes optional memory profiling, resize profiling, pure-planning profiling, the huge fixture tier, and competitor comparisons. Run those focused scripts when deeper diagnostics are needed.
- Fixture tiers: the moderate tier (180 files × 120 lines) backs `large-stream.ts` and `interaction-latency.ts`; the huge tier (1,000 files × 300 lines + one 50,000-line file) backs `huge-stream.ts` and is opt-in because one sample can take minutes before hot-path fixes land.
- Competitor comparisons are informational because installed tool versions and feature parity vary by environment.
- Use `--samples 5` locally when validating borderline changes.
- Set `HUNK_BENCHMARK_TRACE=1` on an interaction benchmark to print the raw per-input latency distribution behind each median and p95.
- Use `benchmarks/results/` for local benchmark output; result files in that directory are ignored by default.

---

### Overview (`examples/README.md`)

# Examples

Ready-to-run demos for Hunk and the exported OpenTUI diff component.

Each folder tells a small review story and includes the exact command to run from the repository root.

## Quick menu

| Example                | Best for                               | Command                                                                                                                                              |
| ---------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `1-hello-diff`         | fastest first run                      | `hunk diff --files examples/1-hello-diff/before.ts examples/1-hello-diff/after.ts`                                                                   |
| `2-mini-app-refactor`  | realistic multi-file review            | `hunk patch examples/2-mini-app-refactor/change.patch`                                                                                               |
| `3-agent-review-demo`  | inline agent rationale                 | `hunk patch examples/3-agent-review-demo/change.patch --agent-context examples/3-agent-review-demo/agent-context.json`                               |
| `4-ui-polish`          | screenshot-friendly TSX diff           | `hunk diff --files examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsx`                                                                   |
| `5-pager-tour`         | line scrolling, paging, and hunk jumps | `hunk diff --files examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts --pager`                                                           |
| `6-readme-screenshot`  | README screenshot with agent notes     | `hunk patch examples/6-readme-screenshot/change.patch --agent-context examples/6-readme-screenshot/agent-context.json --mode split --theme midnight` |
| `7-opentui-component`  | embedding `HunkDiffView` in OpenTUI    | `bun run examples/7-opentui-component/from-files.tsx`                                                                                                |
| `8-opentui-primitives` | composing Hunk's OpenTUI primitives    | `bun run examples/8-opentui-primitives/primitives-demo.tsx`                                                                                          |
| `9-agent-markup-notes` | STML markup rendered inside notes      | `hunk patch examples/9-agent-markup-notes/change.patch --agent-context examples/9-agent-markup-notes/agent-context.json`                             |

## Installable extension examples

- [`extensions/cli-tools/`](extensions/cli-tools/) demonstrates the minimal generic CLI exit and delegation contracts.
- [`extensions/review-triage/`](extensions/review-triage/) adds a session-local hunk triage sidebar.
- [`extensions/review-note-navigator/`](extensions/review-note-navigator/) inventories every saved ReviewStore note and navigates to visible authoritative anchors.
- [`extensions/review-snapshot-export/`](extensions/review-snapshot-export/) exports stable file identities and every saved ReviewStore note with a stale-work guard.
- [`extensions/rendered-markdown/`](extensions/rendered-markdown/) adds an optional parsed Markdown file presentation.
- [`extensions/inline-edit/`](extensions/inline-edit/) edits the file under review in place, composing a file-view mode, layout refresh, and host-mediated workspace writes.
- [`extensions/jsx-file-view/`](extensions/jsx-file-view/) is the smallest hook-using fixed-row JSX proof of concept.
- [`extensions/jsx-file-view-gallery/`](extensions/jsx-file-view-gallery/) runs three constrained-JSX presentations against checked-in TypeScript, CSS, and `package.json` diffs: an impact atlas, real color swatches, and highlighted dependency versions.

Extension examples are not bundled with Hunk. Each README shows how to try or install its folder explicitly.

## Notes

- The patch-based examples include checked-in `change.patch` files, so you can open them without creating a temporary repo.
- The agent demo also includes an `agent-context.json` sidecar to show inline review notes beside the diff.
- The pager tour is intentionally taller than a typical terminal viewport so you can try `↑`, `↓`, `PageUp`, `PageDown`, `Home`, `End`, and `[` / `]` right away.
- The OpenTUI component example folder also includes `from-patch.tsx` if you want the same demo driven by raw unified diff text instead of `before` / `after` contents.
- The OpenTUI primitives example shows how to assemble a custom review UI from Hunk's exported building blocks.

---

### Overview (`nix/README.md`)

# Installing using Nix

Nix users can install Hunk from source instead of using npm.

## Install from a flake

1. Add Hunk to your nix flake inputs like such:

```nix
{
    inputs = {
        hunk = {
          url = "github:modem-dev/hunk";
          inputs.nixpkgs.follows = "nixpkgs";
        };
    }
}
```

2. Use in NixOS `environment.systemPackages`:

```nix
{
  environment.systemPackages = [
    inputs.hunk.packages.${pkgs.stdenv.hostPlatform.system}.hunk
  ];
}
```

Or in Home Manager `home.packages`:

```nix
{
  home.packages = [
    inputs.hunk.packages.${pkgs.stdenv.hostPlatform.system}.hunk
  ];
}
```

## Home Manager

Hunk provides a Home Manager module to manage both the package and its configuration.

1. Add the module to your Home Manager configuration:

```nix
{
  imports = [
    inputs.hunk.homeManagerModules.default
  ];

  programs.hunk = {
    enable = true;
    enableGitIntegration = true; # Optional: set hunk as default git pager
    settings = {
      theme = "graphite";
      mode = "split";
      line_numbers = true;
      tab_width = 4;
    };
  };
}
```

`enableGitIntegration` writes to Home Manager's Git configuration, so it requires Home Manager's Git module to be enabled with `programs.git.enable = true;`.

## Running from a flake

Run Hunk directly with Nix:

```bash
nix run github:modem-dev/hunk -- --help
```

## Updating Hunk

Flake users update Hunk by updating their own pinned `flake.lock` input:

```bash
nix flake lock --update-input hunk
```

## Supported systems

The flake builds for `x86_64-linux`, `aarch64-linux`, and `aarch64-darwin`.

Intel macOS (`x86_64-darwin`) is not exposed, because Nixpkgs 26.11 dropped support for it and merely _declaring_ a dropped system breaks flake evaluation for everyone consuming Hunk. Intel macOS users should install Hunk via npm or Homebrew instead.

If you pin an older Nixpkgs that still supports a system Hunk does not list, point Hunk's `systems` input at a wider list:

```nix
{
  inputs.hunk = {
    url = "github:modem-dev/hunk";
    inputs.nixpkgs.follows = "nixpkgs";
    inputs.systems.url = "github:nix-systems/default"; # adds x86_64-darwin
  };
}
```

The same input also drives bun2nix, so overriding it once widens the whole build. If your flake already has its own `systems` input, `inputs.systems.follows = "systems"` works too.

## Building using Nix

Run `nix build` to build the default package for the current system. The resulting Hunk binary will be `./result/bin/hunk`.

You can also build the named package explicitly:

```bash
nix build .#hunk
```

## Maintainer dependency updates

When JavaScript or Bun dependencies change, regenerate the Nix dependency lockfile and commit it with the dependency change:

```bash
bun run nix:update-lock
```

This script requires Nix and runs the flake-pinned `bun2nix` version from `flake.lock`.

---

### Overview (`scripts/README.md`)

# Repository scripts

Scripts are grouped by the workflow they support. Prefer the stable `bun run` aliases in the root
`package.json` when one exists.

- `benchmarks/` — release and daemon benchmark tooling
- `build/` — source builds and local binary installation
- `dev/` — manual development probes and fixtures
- `generate/` — checked-in documentation, skill, changelog, and theme generation
- `launch-video/` — launch-video capture and composition
- `packaging/` — package assembly, validation, publication, and install checks
- `quality/` — repository architecture, package-contract, and example conformance tests
- `release/` — Changesets and release-channel validation
- `test/` — default test-suite and compatibility runners
- `website/` — website catalogs, helpers, and link checks

Keep tests next to the script or policy they cover.

---

### Overview (`test/README.md`)

# Test layout

Most unit tests are colocated with the source they cover. The top-level `test/` tree holds tests
that cross module, process, repository, runtime, or terminal boundaries.

## Structure

```text
test/
  helpers/                 shared test-only builders, fixtures, and colocated helper tests
  fixtures/                runtime-neutral cross-process fixtures
  cli/                     black-box CLI contracts
  session/                 daemon, broker, and session CLI flows
  review-conformance/      shared semantic fixtures and consumer projections
  session-broker-node/     real Node adapter conformance
  session-broker-runtime/  shared Bun/Node connection fixtures
  pty/                     live PTY-driven UI integration
  smoke/                   opt-in terminal transcript checks
```

## Placement

- Keep a test beside one module or helper when it can exercise that owner directly.
- Put shared unit-test builders in `test/helpers/` and name them explicitly as test-only helpers.
- Use `test/cli/` for spawned entrypoint behavior such as help, version, pager fallback, and errors.
- Use `test/session/` for cross-process daemon, broker, and session CLI flows.
- Use `test/review-conformance/` for hand-authored review semantics and every registered consumer's
  real projection.
- Use `test/session-broker-node/`, `test/session-broker-runtime/`, and `test/fixtures/` for portable
  broker adapter behavior and shared cross-runtime fixtures.
- Use `test/pty/` for resize, navigation, mouse, layout, scrolling, and note visibility in a live UI.
- Use `test/smoke/` for transcript-level rendering checks on a real TTY.

## Commands and coverage

| Command                              | Coverage                                                                                                                                |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `bun run test`                       | `packages/`, `scripts/`, `examples/`, `test/cli/`, `test/helpers/`, and `test/session/`, as defined by `scripts/test/run-test-suite.ts` |
| `bun run test:windows`               | Windows platform, process, VCS, CLI, and packaging coverage; Linux owns the terminal UI semantic suite                                  |
| `bun test ./test/review-conformance` | Shared review fixtures and registered consumer projections                                                                              |
| `bun run test:session-broker-node`   | Real Node listener/adapter conformance using the checked-in cross-runtime fixtures                                                      |
| `bun run test:integration`           | PTY-backed tests under `test/pty/`                                                                                                      |
| `bun run test:tty-smoke`             | Opt-in real-TTY smoke tests under `test/smoke/`                                                                                         |
| `bun run test:install-vm`            | Opt-in Firecracker install compatibility scenarios under `test/cli/install-vm/`                                                         |
| `bun run vm:shell [-- --with-hunk]`  | Disposable Ubuntu Firecracker shell, optionally with a fresh local Hunk build                                                           |

npm extension installation tests require npm/Node.js and use a disposable loopback registry
with real tarballs, not public npm. To exercise both the source CLI and a compiled binary:

```sh
bun run build:bin
HUNK_TEST_BINARY="$PWD/dist/hunk" bun test test/cli/npm-extension-install.test.ts
```

`HUNK_TEST_BINARY` is optional; the source CLI case runs in the default suite. On Windows,
set that environment variable to the absolute path of `dist/hunk.exe` before running Bun.

The dedicated trees are not selected directly by `bun run test`, though package tests import some
shared runtime fixtures. Run the commands that match the changed behavior; omission from the default
suite does not mean another command covers it.

## Shell completion compatibility

`test/pty/shell-completion-integration.test.ts` presses Tab in real Bash Readline and Zsh ZLE
sessions. It covers standalone Bash, Bash with an installed `bash-completion` helper, and sourced
and autoloaded Zsh scripts. The fixture's `hunk` shell function only records the resulting
arguments; it never launches the application. These tests are Unix-only and skip missing shells.

Run them directly with:

```bash
bun test test/pty/shell-completion-integration.test.ts
```

Set `HUNK_TEST_BASH_COMPLETION` to test a helper installed outside the detected system/Homebrew
locations. To verify standalone compatibility with a particular Bash executable, including
macOS's Bash 3.2, use:

```bash
HUNK_TEST_BASH_EXECUTABLE=/bin/bash bun test \
  packages/hunk/src/app/shellCompletion.test.ts \
  test/pty/shell-completion-integration.test.ts \
  --test-name-pattern 'shell completion scripts|bash standalone'
```

Replace `/bin/bash` with the desired executable. Run the helper-backed case with the Bash version
supported by that installed helper; modern `bash-completion` versions may themselves require
Bash 4 or newer.

The Windows suite omits the platform-neutral terminal UI semantic tree, which the Linux suite covers,
then runs the focused UI tests for worker startup, editor commands, and workspace path safety. Add new
UI tests to the `windows-ui` group when their behavior depends on Windows platform boundaries.

---

### Overview (`benchmarks/release/README.md`)

# Release benchmark snapshots

Committed files in this directory are the performance baselines used by the release workflow. They are intentionally versioned so a release can be audited after publishing.

## Release prep

Before pushing a release tag, run the benchmark suite for the version in `package.json`:

```bash
bun run bench:release
```

This writes:

```text
benchmarks/release/bench-x.y.z.json
```

Then compare it against the latest lower stable release snapshot:

```bash
bun run bench:release:compare
```

Commit the new `bench-x.y.z.json` file with the release-prep change. The tag release workflow validates that this file exists and fails before publishing npm packages if the comparison finds a material regression.

## Regression policy

The gate compares benchmark medians and only fails on regressions that exceed both the relative and absolute thresholds embedded in the benchmark result metadata:

- timing metrics: default `+15%` and at least `+5ms`
- memory metrics: default `+20%` and at least `+8MiB`

New metrics are informational until a later release has a baseline. Missing previously comparable metrics fail, because that means the gate can no longer protect that measurement.

If a release intentionally accepts a measured tradeoff, record the metric names and rationale in the new snapshot's `acceptedRegressions` array. The comparison report will mark those rows as accepted instead of failing, while preserving the audit trail in the committed release artifact.

## Backfilling

When adding this gate or restoring a missing baseline, check out the release tag and generate the snapshot with the same Bun version and runner class used for current release prep. Commit backfilled snapshots before relying on the release gate.

---

### Overview (`examples/1-hello-diff/README.md`)

# 1-hello-diff

A tiny first-run demo with one clean TypeScript diff.

## Run

```bash
hunk diff --files examples/1-hello-diff/before.ts examples/1-hello-diff/after.ts
```

## What to look for

- a renamed type and function parameter
- a small helper extraction
- obvious intra-line changes in strings and copy

---

### Overview (`examples/2-mini-app-refactor/README.md`)

# 2-mini-app-refactor

A realistic multi-file demo where a tiny morning-summary app gets reorganized into grouped output.

## Run

```bash
hunk patch examples/2-mini-app-refactor/change.patch
```

## What to look for

- a multi-file review stream in sidebar order
- a new helper module added mid-change
- the entrypoint switching from flat output to grouped sections
- a matching validation-file update at the end of the review

---

### Overview (`examples/3-agent-review-demo/README.md`)

# 3-agent-review-demo

A flagship Hunk demo: a small command-palette refactor with inline agent rationale attached to the interesting hunks.

## Run

```bash
hunk patch examples/3-agent-review-demo/change.patch \
  --agent-context examples/3-agent-review-demo/agent-context.json
```

## What to look for

- query normalization extracted into its own helper
- ranking logic that prefers strong matches over loose substring hits
- inline notes beside the changed hunks, not in a separate panel or PR description

---

### Overview (`examples/4-ui-polish/README.md`)

# 4-ui-polish

A screenshot-friendly TSX diff with copy edits, prop renames, and a small layout cleanup.

## Run

```bash
hunk diff --files examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsx
```

## What to look for

- renamed props and extracted button-label helper
- nice intra-line emphasis in strings and labels
- a compact UI-focused diff that looks good in split and unified layouts

---

### Overview (`examples/5-pager-tour/README.md`)

# 5-pager-tour

A tall single-file diff made to show line scrolling, paging, and hunk jumps.

## Run

```bash
hunk diff --files examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts --pager
```

## What to look for

- enough changed content to exceed a normal terminal viewport
- `↑` and `↓` for line-by-line movement
- `PageUp`, `PageDown`, `Home`, and `End` for larger jumps
- multiple hunks so `[` and `]` are worth trying too

---

### Overview (`examples/6-readme-screenshot/README.md`)

# 6-readme-screenshot

A screenshot-optimized demo for the main README: a multi-file UI refactor with inline agent rationale.

## Run

```bash
hunk patch examples/6-readme-screenshot/change.patch \
  --agent-context examples/6-readme-screenshot/agent-context.json \
  --mode split \
  --theme github-dark-default
```

## Screenshot setup

- use a wide terminal so the sidebar and split diff are both visible
- keep the first file selected: `src/components/ReviewSummaryCard.tsx`
- make sure agent notes are visible
- capture the first annotated hunk with the note popover open

## What it shows well

- inline agent rationale beside the changed code
- a clear mix of removed and added lines in one hunk
- a visible multi-file sidebar
- TSX prop renames, copy edits, and helper extraction with strong syntax color

---

### Overview (`examples/7-opentui-component/README.md`)

# 7-opentui-component

Two minimal OpenTUI apps that embed `HunkDiffView` directly.

For package install and API details, see [OpenTUI component docs](../../docs/opentui-component.md).

## Run

```bash
bun run examples/7-opentui-component/from-files.tsx
bun run examples/7-opentui-component/from-patch.tsx
```

## What it shows

- embedding `HunkDiffView` inside a normal OpenTUI app shell
- building `diff.metadata` with `parseDiffFromFile`
- parsing raw unified diff text with `parsePatchFiles`
- switching between split and unified layouts with example shell controls
- a scrollable terminal diff component that other OpenTUI apps can reuse

The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead.

---

### Overview (`examples/8-opentui-primitives/README.md`)

# 8-opentui-primitives

A small custom OpenTUI app assembled from Hunk's lower-level primitives instead of the full Hunk CLI app shell.

For package install and API details, see [OpenTUI component docs](../../docs/opentui-component.md).

## Run

```bash
bun run examples/8-opentui-primitives/primitives-demo.tsx
```

## What it shows

- `createHunkDiffFilesFromPatch` for turning unified diff text into public Hunk file models
- `HunkFileNav` for a standalone file list
- `HunkReviewStream` for a multi-file review stream without Hunk's menu bar or global shortcuts
- `HunkDiffFileHeader` and `HunkDiffBody` for a single-file view assembled by the host app
- Host-owned window borders/chrome around each primitive so you can inspect component boundaries
- Host-owned state for selected file and split/unified layout

The in-repo demo imports from `../../packages/hunk/src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead.

---

### Overview (`examples/9-agent-markup-notes/README.md`)

# 9 — agent markup notes (STML)

Shows agent notes that carry **STML markup** — a small HTML-like markup that
Hunk renders as real terminal UI inside the inline note card: bordered boxes,
rows of shapes, lists, badges, and code blocks instead of plain text.

Run from the repository root:

```sh
hunk patch examples/9-agent-markup-notes/change.patch \
  --agent-context examples/9-agent-markup-notes/agent-context.json
```

Press `a` to reveal the agent notes for the selected hunk.

The same markup works for live comments from an agent driving a session:

```sh
hunk session comment add --repo . --file src/retry.ts --new-line 3 \
  --summary "Retry flow" \
  --markup '<box border border-color="accent">shapes in a note</box>' \
  --focus
```

Learn and iterate from the CLI:

```sh
hunk markup guide                                  # authoring guide with copy-paste patterns
echo '<badge color="success">OK</badge> ready' | \
  hunk markup render - --width 56                  # preview before publishing
```

Tags: block (`box`, `card`, `row`, `text`, `h1`–`h3`, `list`/`item`, `hr`,
`spacer`, `code`) and inline (`b`, `i`, `u`, `s`, `dim`, `color`, `kbd`,
`badge`, `a`, `br`). Colors accept semantic tokens (`accent`, `success`,
`warning`, `danger`, `info`, `muted`), ANSI-style names, or hex.

---

### Overview (`packages/hunk-git/README.md`)

# @hunk/git

Private, statically bundled Git provider for Hunk. This `0.0.0` workspace is not a public or
versioned SDK.

The package exports its provider from `src/index.ts`. It supports working-tree, revision, and stash
reviews; history; exact old/new source reads; staged changes; untracked and large-file handling;
and filesystem-assisted watch plans.

Provider code may import local modules, platform built-ins, the public `hunkdiff/extension`
contract, and explicit `@hunk/vcs/*` leaves. It must not import Hunk core, app, session, or UI
internals.

Hunk registers the provider in `packages/hunk/src/extensions/default/vcs/index.ts` and composes it
into the provider-neutral catalog in `packages/hunk/src/app/vcsCatalog.ts`. Keep tests beside the
provider source. Run `bun test packages/hunk-git` and `bun run deps:check` after changes.

---

### Overview (`packages/hunk-jj/README.md`)

# @hunk/jj

Private, statically bundled Jujutsu provider for Hunk. This `0.0.0` workspace is not a public or
versioned SDK.

The package exports its provider from `src/index.ts`. It supports working-copy and revision reviews,
history, exact source reads where the revision shape permits them, and signature-based refresh.
Staged and stash reviews are not supported.

Provider code may import local modules, platform built-ins, the public `hunkdiff/extension`
contract, and explicit `@hunk/vcs/*` leaves. It must not import Hunk core, app, session, or UI
internals.

Hunk registers the provider in `packages/hunk/src/extensions/default/vcs/index.ts` and composes it
into the provider-neutral catalog in `packages/hunk/src/app/vcsCatalog.ts`. Keep tests beside the
provider source. Run `bun test packages/hunk-jj` and `bun run deps:check` after changes.

---

### Overview (`packages/hunk-sapling/README.md`)

# @hunk/sapling

Private, statically bundled Sapling provider for Hunk. This `0.0.0` workspace is not a public or
versioned SDK.

The package exports its provider from `src/index.ts`. It supports working-copy and revision reviews,
untracked-file discovery, and signature-based refresh. History, staged and stash reviews, exact
source reads, and filesystem watch plans are not supported.

Provider code may import local modules, platform built-ins, the public `hunkdiff/extension`
contract, and explicit `@hunk/vcs/*` leaves. It must not import Hunk core, app, session, or UI
internals.

Hunk registers the provider in `packages/hunk/src/extensions/default/vcs/index.ts` and composes it
into the provider-neutral catalog in `packages/hunk/src/app/vcsCatalog.ts`. Keep tests beside the
provider source. Run `bun test packages/hunk-sapling` and `bun run deps:check` after changes.

---

### Overview (`packages/hunk-vcs/README.md`)

# @hunk/vcs

Private implementation helpers shared by Hunk's bundled VCS providers. This `0.0.0` workspace is
not a public or versioned SDK.

The package has no root export. Providers import only the leaf they need:

- `@hunk/vcs/async-process`
- `@hunk/vcs/diff-target`
- `@hunk/vcs/large-file`
- `@hunk/vcs/path`
- `@hunk/vcs/source`

`@hunk/vcs` sits at the bottom of the workspace graph and does not import other workspace packages.
Provider implementations combine these helpers with the public `hunkdiff/extension` contract; Hunk
registers them in `packages/hunk/src/extensions/default/vcs/index.ts` and composes the catalog in
`packages/hunk/src/app/vcsCatalog.ts`.

Keep unit tests beside the source. Run `bun test packages/hunk-vcs` and `bun run deps:check` after
changing this package.

---

### Overview (`packages/hunk/README.md`)

# hunk

Hunk is an interactive terminal diff viewer for reviewing code changes, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs).

**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/)

[](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main)
[](https://github.com/modem-dev/hunk/releases)
[](LICENSE)
[](https://discord.gg/WZFjaP6Gt8)

- multi-file review stream with sidebar navigation
- inline AI and agent annotations beside the code
- split, unified, and responsive auto layouts
- watch mode for auto-reloading file and Git-backed reviews
- keyboard, mouse, pager, and Git difftool support

<table>
 <tr>
   <td width="60%" align="center">
     <img width="845" alt="image" src="https://raw.githubusercontent.com/modem-dev/hunk/refs/heads/main/website/public/docs/images/review-stream.webp" />
     <br />
     <sub>Split view with sidebar and inline AI notes</sub>
   </td>
   <td width="40%" align="center">
     <img width="507"alt="image" src="https://raw.githubusercontent.com/modem-dev/hunk/refs/heads/main/website/public/docs/images/agent-comments.webp" />
     <br />
     <sub>Unified view and mouse-selectable menus</sub>
   </td>
 </tr>
</table>

## Install

On macOS and Linux, install the standalone binary:

```bash
curl -fsSL https://hunk.dev/install.sh | sh
```

Windows users can install with npm or mise. Other installation methods are also available:

```bash
npm i hunkdiff                    # macOS, Linux, or Windows; requires Node.js 22+
brew install hunk                    # macOS or Linux
mise use -g hunk                     # macOS, Linux, or Windows
```

> [!NOTE]
> If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`.

Windows requires mise 2026.8.6 or newer. Nix users can use the `default` package exported in `flake.nix`; see [the Nix guide](https://github.com/modem-dev/hunk/blob/main/nix/README.md) for details. Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise.

Requirements:

- macOS (arm64, x64), Linux (arm64, x64; glibc), or Windows (x64); prebuilt binaries are not published for Windows on arm64 or musl-based Linux such as Alpine
- On x86-64, a CPU with SSE4.2 (Intel Nehalem 2008+, AMD Bulldozer 2011+); arm64 has no CPU feature floor
- Node.js 22+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary that does not require Node.js
- Git recommended for most workflows

### Update Hunk

For npm, Homebrew, and default install-script installs (Hunk 0.20+):

```bash
hunk update          # install the newest release
hunk update --check  # check without installing
hunk update 0.20.0   # select an exact npm or default install-script release
```

On an older release, update once with the installer or package manager that installed Hunk, then use `hunk update` going forward. Custom `HUNK_INSTALL_DIR` installs must re-run the installer with the same directory; mise, Nix, and source installs use their owning tools instead.

## Quick start

```bash
hunk           # show help
hunk --version # print the installed version
```

### Working with Git

Use familiar Git commands to open changes in Hunk:

```bash
hunk diff                      # review current repo changes, including untracked files
hunk diff --watch              # auto-reload as the working tree changes
hunk show                      # review the latest commit
hunk show HEAD~1               # review an earlier commit
hunk log                       # browse history on a terminal; print when redirected
hunk log --static              # force static output, paging when needed
hunk status                    # view workspace status and sibling worktrees (Git)
hunk status --static           # print one read-only workspace snapshot
hunk status --json             # emit one versioned workspace snapshot
```

`hunk status` is read-only: it does not fetch or change the repository. See [workspace status](docs/status.md) for details.

`hunk log` opens an interactive history browser in a terminal and prints text when piped or redirected. Use `--oneline` for compact output. In the browser, enable **Graph view** from the View menu to see commit branches, or press `v` to select a commit range. Quit a commit review to return to history.

### Working with GitHub

Hunk bundles `hunk gh`, so pull requests, commits, and comparisons open directly without the
GitHub CLI or a separate extension:

```bash
hunk gh pr                         # infer the open PR for the current branch
hunk gh pr 123                     # infer the repository from origin
hunk gh pr 'modem-dev/hunk#123'    # quote this form for shells that treat # as a comment
hunk gh commit a1b2c3d --repo modem-dev/hunk
hunk gh compare main...feature --repo modem-dev/hunk
```

Public repositories work anonymously. Set `GH_TOKEN`, or `GITHUB_TOKEN` when it is absent, for
private repositories and higher API limits. GitHub Enterprise is not currently supported.

### Working with Jujutsu and Sapling

Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config).

### Viewing complete files and browsing directories

```bash
hunk open                 # browse the current directory
hunk open README.md       # view a complete, syntax-highlighted document
hunk open path/to/project # browse another directory without requiring a repository
```

Use arrows and Enter to navigate, Tab to switch focus, `i` to show hidden/Git-ignored entries, and `e` to edit the displayed file in `$EDITOR`. See [the open workflow](docs/open.md) for details and file limits.

### Working with raw files and patches

```bash
hunk diff --files before.ts after.ts        # compare two files directly
hunk diff --files before.ts after.ts --watch # auto-reload when either file changes
git diff --no-color | hunk patch -          # review a patch from stdin
```

### Working with agents

1. Install the review skill once: `npx skills add modem-dev/hunk -g --skill hunk-review`.
2. Open Hunk in another terminal with `hunk diff` or `hunk show`.
3. Ask your agent to use the hunk-review skill against the live Hunk session.
4. Press `a` in Hunk to show agent notes; they start hidden.

If your agent has no skill system, ask it to run `hunk skill show` and follow the output.

See [Review with an agent](https://hunk.dev/docs/agents/review-with-an-agent/) for live sessions, `--agent-context`, and experimental rich notes.

## Feature comparison

| Capability                           | [hunk](https://github.com/modem-dev/hunk) | [lumen](https://github.com/jnsahaj/lumen) | [difftastic](https://github.com/Wilfred/difftastic) | [delta](https://github.com/dandavison/delta) | [diff-so-fancy](https://github.com/so-fancy/diff-so-fancy) | [diff](https://www.gnu.org/software/diffutils/) |
| ------------------------------------ | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------- |
| Review-first interactive UI          | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Multi-file review stream + sidebar   | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Inline agent / AI annotations        | ✅                                        | ❌                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Responsive auto split/unified layout | ✅                                        | ❌                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Mouse support inside the viewer      | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Runtime view toggles                 | ✅                                        | ✅                                        | ❌                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Syntax highlighting                  | ✅                                        | ✅                                        | ✅                                                  | ✅                                           | ❌                                                         | ❌                                              |
| Structural diffing                   | ❌                                        | ❌                                        | ✅                                                  | ❌                                           | ❌                                                         | ❌                                              |
| Pager-compatible mode                | ✅                                        | ❌                                        | ✅                                                  | ✅                                           | ✅                                                         | ✅                                              |

## Advanced

### Config

You can persist preferences to a config file:

- `$XDG_CONFIG_HOME/hunk/config.toml` (default `~/.config/hunk/config.toml`)
- `.hunk/config.toml` in a repository

Persistent state uses `$XDG_STATE_HOME/hunk/state.json` when the variable is
absolute, or `~/.local/state/hunk/state.json` otherwise. Hunk preserves existing
state by copying the old config-directory file on first use, keeping the original
and falling back to it if migration fails. Global extension paths are unchanged.

For example:

```toml
theme = "github-dark-default"
mode = "auto"               # auto, split, unified
auto_split_min_width = 160  # minimum available diff columns for split in auto mode
line_numbers = true
wrap_lines = false
```

See the [configuration guide](https://hunk.dev/docs/configure/configuration/) for command overrides and examples, the [full reference](https://hunk.dev/docs/reference/config/) for every option, or the [theme guide](https://hunk.dev/docs/configure/themes/) to customize colors.

### Keybindings

Remap shortcuts with a `[keybindings]` table in your user config. See [the keybinding guide](https://hunk.dev/docs/configure/keybindings/) for available commands, default keys, and examples.

### Git integration

Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically:

> [!NOTE]
> Untracked files are auto-included only for Hunk's own `hunk diff` working-tree loader. If you open `git diff` through `hunk pager`, Git still decides the patch contents, so untracked files will not appear there.

```bash
git config --global core.pager "hunk pager"
```

Or in your Git config:

```ini
[core]
    pager = hunk pager
```

If you want to keep Git's default pager and add opt-in aliases instead:

```bash
git config --global alias.hdiff "-c core.pager=\"hunk pager\" diff"
git config --global alias.hshow "-c core.pager=\"hunk pager\" show"
```

### Jujutsu pager integration

To use Hunk as jj's pager, run `jj config edit --user` and update:

```toml
[ui]
pager = ["hunk", "pager"]
diff-formatter = ":git"
```

### Sapling pager integration

To use Hunk as Sapling's pager, run `sl config -u` and update:

```ini
[pager]
pager = hunk pager
```

### Extensions (experimental)

The extension API is experimental and may change in breaking ways between
minor releases while it stabilizes; breaking changes are called out in
release notes.

Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a
repository's `.hunk/extensions/` (after you explicitly trust that repository),
and from `--extension <path>` for development. `--no-extensions` turns those off
for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded.

Extensions can add commands, themes, VCS backends, custom sidebars, and more. For example, filter lockfiles out of a review:

```ts
// ~/.config/hunk/extensions/collapse-lockfiles.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.transformChangeset((changeset, ctx) => {
    const files = changeset.files.filter((file) => !file.path.endsWith(".lock"));
    ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`);
    return { ...changeset, files };
  });
}
```

Extensions shared as git repositories install straight from their host, and a
`hunk-extension` GitHub topic marks community ones:

```bash
hunk extension install acme/hunk-word-diff@v1.2.0   # or git:host/path, a URL, a local path
hunk extension list                                 # then update [name] / remove <name>
```

Browse community extensions at
[github.com/topics/hunk-extension](https://github.com/topics/hunk-extension);
publish yours by pushing the extension to a repository root and adding that
topic.

See [the extension guide](https://hunk.dev/docs/extend/extensions/) for the API, configuration, and publishing instructions.
Installable examples include
[review triage](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-triage),
[review snapshot export](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export), an optional
[rendered Markdown file view](https://github.com/modem-dev/hunk/tree/main/examples/extensions/rendered-markdown), and a
[Vim navigation mode](https://github.com/modem-dev/hunk/tree/main/examples/extensions/vim-navigation) built from public semantic commands.

### OpenTUI component

Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app.

See [the OpenTUI component guide](https://hunk.dev/docs/reference/opentui-components/) for install, API, and runnable examples.

## Examples

Ready-to-run demo diffs live in [the examples directory](https://github.com/modem-dev/hunk/tree/main/examples).

Each example includes the exact command to run from the repository root.

## Contributing

💬 _Chat with users/contributors on the [Modem Discord server](https://discord.gg/WZFjaP6Gt8)_

See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and contribution guidelines.

## Sponsor

Sponsored by [Modem](https://modem.dev?utm_source=github&utm_medium=oss&utm_campaign=oss_hunk&utm_content=readme_footer).

<a href="https://modem.dev?utm_source=github&utm_medium=oss&utm_campaign=oss_hunk&utm_content=readme_footer">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://modem.dev/images/logo/svg/modem-combined-white.svg">
    <source media="(prefers-color-scheme: light)" srcset="https://modem.dev/images/logo/svg/modem-combined-black.svg">
    <img src="https://modem.dev/images/logo/svg/modem-combined-black.svg" alt="Modem" width="220">
  </picture>
</a>

## License

[MIT](https://github.com/modem-dev/hunk/blob/main/LICENSE)

---

### Overview (`packages/session-broker-bun/README.md`)

# @hunk/session-broker-bun

Private Bun HTTP and websocket adapter for `@hunk/session-broker`.

Use this workspace to serve a runtime-neutral `SessionBrokerDaemon` through `Bun.serve(...)`.
It is not a published, versioned SDK package. See the main broker README for current internal API
construction and the [session broker SDK contract](../../docs/session-broker-sdk.md) for the future
public package.

## What it does

- binds a broker daemon to a Bun HTTP server
- upgrades websocket requests on the daemon socket path
- forwards websocket messages and close events into the daemon
- exposes a `stopped` promise compatible with Hunk's daemon lifecycle
- lets callers override or add custom HTTP routes before the daemon's built-in routes

## Usage

Create the daemon with steps 1–2 in
[`@hunk/session-broker`](../session-broker/README.md), then bind it to Bun:

```ts
import type { SessionBrokerDaemon } from "@hunk/session-broker";
import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun";

declare const daemon: SessionBrokerDaemon;

const server = serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});
```

## Custom routes

You can override or extend request handling with `handleRequest`.

```ts
const server = serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
  handleRequest: async (request) => {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return Response.json({ ok: true, overridden: true });
    }

    return undefined;
  },
});
```

Return `undefined` to fall through to the daemon's built-in routes. The raw `/broker` HTTP API is available only when the daemon was created with `exposeHttpApi: true`.

## License

MIT

---

### Overview (`packages/session-broker-core/README.md`)

# @hunk/session-broker-core

Low-level shared primitives for the session broker packages.

This package is an **internal foundation layer**, not the main entrypoint you should build against in most cases.

## Use this package when

- you are working on broker internals
- you need the shared envelope types and parsers directly
- you are implementing a higher-level broker package on top of it

## Prefer these packages for normal use

- `@hunk/session-broker` — main runtime-neutral broker API
- `@hunk/session-broker-bun` — Bun runtime adapter
- `@hunk/session-broker-node` — Node runtime adapter

## What this package includes

- shared session envelope types
- registration and snapshot wire parsing helpers
- low-level in-memory `SessionBrokerState`
- selector helpers for `sessionId`, `sessionPath`, and `repoRoot`
- generic terminal metadata capture

## What this package does not include

- daemon behavior
- session-side websocket lifecycle helpers
- Bun or Node listener setup
- app-specific command semantics or projections

Those higher-level concerns live in the packages above.

## Package boundary

The intended split is:

- **`@hunk/session-broker-core`** — low-level primitives
- **`@hunk/session-broker`** — main broker API
- **runtime adapters** — Bun and Node listener bindings

## Quick example

```ts
import {
  brokerWireParsers,
  parseSessionRegistrationEnvelope,
  parseSessionSnapshotEnvelope,
  SessionBrokerState,
} from "@hunk/session-broker-core";
```

If you find yourself reaching for this package directly in app code, double-check whether `@hunk/session-broker` would be the better fit.

## License

MIT

---

### Overview (`packages/session-broker-node/README.md`)

# @hunk/session-broker-node

Private Node HTTP and websocket adapter for `@hunk/session-broker`.

Use this workspace to serve the broker daemon under Node instead of Bun. It is not a published,
versioned SDK package. See the main broker README for current internal API construction and the
[session broker SDK contract](../../docs/session-broker-sdk.md) for the future public package.

## What it does

- serves a runtime-neutral `SessionBrokerDaemon` through Node HTTP
- upgrades websocket requests with `ws`
- forwards websocket messages and close events into the daemon
- exposes async startup and shutdown helpers
- keeps the runtime-specific listener code out of `@hunk/session-broker`

## Usage

Create the daemon with steps 1–2 in
[`@hunk/session-broker`](../session-broker/README.md), then bind it to Node:

```ts
import type { SessionBrokerDaemon } from "@hunk/session-broker";
import { serveSessionBrokerDaemon } from "@hunk/session-broker-node";

declare const daemon: SessionBrokerDaemon;

const server = await serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});
```

## Why this package exists

This package runs the shared broker API against Node listener and websocket primitives.

If the Node adapter needs an abstraction the shared package does not provide, the fix should happen in `@hunk/session-broker`, not as Node-only glue.

## License

MIT

---

### Overview (`packages/session-broker/README.md`)

# @hunk/session-broker

Runtime-neutral session broker daemon and connection helpers.

The [session broker SDK contract](https://github.com/modem-dev/hunk/blob/main/docs/session-broker-sdk.md)
describes the future supported public package. These private `0.0.0` source workspaces predate that
contract and do not yet satisfy all of its security, compatibility, supervision, packaging, or
release gates.

The current package provides signed producer/caller hello, short-lived caller sessions, replay
admission, and default-deny raw HTTP authorization primitives. Hunk discovers app-scoped
credentials, starts session producers, and authenticates callers in its own runtime adapters. The
legacy Hunk WebSocket and custom session routes remain internal-only; no bearer fallback is
available. Browser-review capabilities are separate from broker caller credentials.

This is the **main broker package** in the workspace. It owns the reusable broker behavior without committing to Bun or Node server APIs.

Use this package when you want to:

- track live sessions
- register and update session snapshots
- route commands to one live session
- expose broker health and optional raw list/get/dispatch APIs
- manage session-side websocket connection state

## Current workspace roles

The source tree is currently split into four private workspaces:

- `@hunk/session-broker-core` — low-level shared primitives and envelope parsing
- `@hunk/session-broker` — **main runtime-neutral broker API**
- `@hunk/session-broker-bun` — Bun HTTP/websocket adapter
- `@hunk/session-broker-node` — Node HTTP/websocket adapter

The public SDK contract consolidates these into one `@hunk/session-broker` package whose root
selects Node or Bun automatically. Until that migration lands, these names and examples describe
internal workspace usage only.

## What this package owns

- `SessionBroker` raw session registry
- `SessionBrokerDaemon` runtime-neutral daemon engine
- `SessionBrokerConnection` runtime-neutral session-side websocket helper
- raw broker HTTP request types
- health handling and optional capabilities API handling
- stale-session pruning and idle shutdown

## What this package does not own

- Bun `Bun.serve(...)`
- Node `http` / `ws` listener setup
- app-specific command semantics
- app-specific projections like Hunk review exports, comments, or selected hunks
- daemon process launch policy

## Current internal quick start

### 1. Create a broker

```ts
import {
  SessionBroker,
  brokerWireParsers,
  createSessionBrokerProtocolParsers,
  parseSessionRegistrationEnvelope,
  parseSessionSnapshotEnvelope,
} from "@hunk/session-broker";

interface SessionInfo {
  title: string;
}

interface SessionState {
  selectedIndex: number;
}

function parseInfo(value: unknown): SessionInfo | null {
  const record = brokerWireParsers.asRecord(value);
  if (!record) {
    return null;
  }

  const title = brokerWireParsers.parseRequiredString(record.title);
  return title === null ? null : { title };
}

function parseState(value: unknown): SessionState | null {
  const record = brokerWireParsers.asRecord(value);
  if (!record) {
    return null;
  }

  const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex);
  return selectedIndex === null ? null : { selectedIndex };
}

const protocolParsers = createSessionBrokerProtocolParsers({
  appRevision: 1,
  features: [],
  parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo),
  parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState),
  commands: [
    {
      command: "select",
      version: 1,
      parseInput: (value) => (brokerWireParsers.parseNonNegativeInt(value) === null ? null : value),
      parseResult: (value) => (value === true ? true : null),
    },
  ],
});

const broker = new SessionBroker({ protocolParsers });
```

### 2. Create a daemon engine

```ts
import { createSessionBrokerDaemon } from "@hunk/session-broker";

const daemon = createSessionBrokerDaemon({
  broker,
  capabilities: {
    version: 1,
    name: "example-broker",
  },
});
```

At this point the daemon can:

- handle health requests
- process websocket register/snapshot/heartbeat/result messages
- prune stale sessions and request idle shutdown

The raw HTTP broker API is opt-in and fails closed: `exposeHttpApi: true` exposes no control route
unless an explicit immutable `appId`, a singleton `appRevision`, a `callerAuthenticator`, and an app
`authorizer` are supplied. The included
`SessionBrokerAuthenticator` implements Ed25519 challenge/proof and signed requests; applications
inject app-scoped grants, public verifiers, daemon signing identity, revocation policy, and their
own default-deny authorization hook. It performs no filesystem, environment, coordinator, or Hunk
credential discovery.

### 3. Serve it through a runtime adapter

#### Bun

```ts
import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun";

const server = serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});
```

#### Node

```ts
import { serveSessionBrokerDaemon } from "@hunk/session-broker-node";

const server = await serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});
```

## Session-side connection helper

Use `SessionBrokerConnection` when an app window or live process needs to stay registered with the broker.

```ts
import {
  createNativeSessionBrokerLifecycleClock,
  createSessionBrokerConnection,
  type SessionBrokerSocketLike,
} from "@hunk/session-broker";

const lifecycleClock = createNativeSessionBrokerLifecycleClock();
const connection = createSessionBrokerConnection({
  url: "ws://127.0.0.1:47657/session",
  createSocket: (url) => new WebSocket(url) as unknown as SessionBrokerSocketLike,
  registration,
  snapshot,
  protocolParsers,
  lifecycleClock,
  bridge: {
    dispatchCommand: async (message) => {
      if (message.command !== "select") throw new Error("Unsupported command.");
      selectFile(message.input);
      return true;
    },
  },
});

connection.start();
```

`lifecycleClock` may be supplied in the connection options when an application needs to share
lifecycle timing with its launcher or make producer scheduling deterministic. The ordinary
`SessionBrokerLifecycleClock` contract provides current time, one-shot scheduling, delayed-first
fixed-rate interval scheduling, and awaitable delay. Scheduled callbacks return idempotent
disposers. `createNativeSessionBrokerLifecycleClock()` uses unref'd native timers so pending
handshake, heartbeat, reconnect, and polling work does not retain the process.

`createSocket` must return a fresh socket object for every generation because the helper installs
property callbacks that cannot distinguish queued events after object reuse. Reconnect and close
callbacks receive a frozen opaque generation token; after foreign work settles, use
`connection.isGenerationCurrent(token)` before committing app-owned state. These checks fence
commits only: they do not cancel foreign promises, cryptography, probes, or other work already in
progress.

Unexpected defects in connection-owned native callbacks, scheduled work, or fire-and-forget work
terminally retire that connection. Applications may supply `onDefect` to observe the event. The
callback receives only `SESSION_BROKER_LIFECYCLE_DEFECT_MESSAGE`; thrown values and lifecycle data
are never forwarded, the callback runs at most once, and callback failures are contained. This
runtime-neutral package does not write defect reports to process output.

The helper owns:

- initial `register`
- later `snapshot` updates
- heartbeats
- `command-result` replies
- queued broker commands until the bridge is ready
- reconnect scheduling

## Raw broker API

The daemon always serves `GET /health`. Its raw capability/control API is intentionally small and
disabled by default. When `exposeHttpApi: true` is set together with an explicit `appId`, singleton
`appRevision`, caller authenticator, and authorizer, it additionally serves:

- `GET /broker/capabilities`
- `POST /broker`

Request body shapes:

```ts
{ action: "list" }
{ action: "get", selector: { sessionId: "..." } }
{ action: "dispatch", selector: { sessionId: "..." }, command: "...", commandVersion: 1, input: {...} }
```

An omitted `commandVersion` is validated and deliberately defaults to revision `1` for current
internal callers. Authentication covers the exact bounded HTTP body bytes before strict UTF-8 and
JSON decoding. Authenticated responses use `{ body, authentication }`; the signed authentication
record binds daemon generation, broker revision, target application contract when applicable,
request ID, HTTP status, and the canonical structured-body digest.

## Hunk-specific layering

Hunk uses this package for generic broker lifecycle and keeps product behavior in `packages/hunk`:

- credential discovery: `packages/hunk/src/session/broker/credentials.ts`
- daemon and Hunk-specific routes: `packages/hunk/src/session/broker/brokerServer.ts`
- authenticated agent caller: `packages/hunk/src/session/agent/cliClient.ts`
- session commands and review projections: `packages/hunk/src/session/`

The broker owns transport and lifecycle; Hunk owns session meaning and policy.

## License

MIT

---

### Overview (`packages/term-video/README.md`)

# @hunk/term-video

Keyframe-based terminal product videos. Instead of screen-recording in real
time, a script drives the real TUI over a PTY and snaps discrete styled
keyframes; a storyboard then declares how long each state holds and which
caption accompanies it; Chromium composites each frame onto a stage (window
chrome, captions, title cards); ffmpeg encodes the result. Pacing is
deterministic, a minute of video is ~300 unique frames, and every frame is an
inspectable PNG before anything is encoded.

## Entry points

- `@hunk/term-video/capture` (Bun) — PTY driving and keyframe snapping:
  `createKeyframer`, `launchApp`, `launchShell`, `createCommandWrapper`,
  `typeCommand`, `ensureKeyboardIsLive`, `makeSceneFilter`.
- `@hunk/term-video/plan` (Bun or Node) — pure storyboard→frame-plan
  expansion: `planFrames`, `requiredKeyframes`. Unit-tested; owns caption
  carry-forward plus camera-pan, zoom, and source-aligned highlight animation.
- `@hunk/term-video/compose` (Node ≥ 22) — `composeStoryboard` renders the
  plan in headless Chromium against `src/stage.html` (or a custom stage) and
  writes PNG frames plus an ffmpeg concat list. `playwright-core` is resolved
  from the caller's work directory so its version can match the driven
  Chromium build.

- `@hunk/term-video/timeline` (Node ≥ 22) — `renderTimeline` steps a free-form
  motion page frame by frame instead of planning stage states. The page owns
  all of its motion and exposes `timelineReady`, `TIMELINE_DURATION`, and a
  pure `renderAt(t)`, so any frame renders on its own for review stills.
  `frameTimes` and `encodeArgs` are the unit-tested sampling and encoding
  pieces.

## Consumers

`scripts/launch-video/` holds Hunk's current storyboard (scenes, captions,
cards) on top of this package, and `scripts/launch-video/promo/` holds Hunk's
release-promo style kit rendered through `timeline`; `skills/hunk-launch-video/SKILL.md` documents the
end-to-end workflow and recipes for single-feature, custom, and full-release
videos, environment gotchas included.

---

### Overview (`scripts/launch-video/README.md`)

# Hunk video pipeline

Generates Hunk product videos from real Hunk sessions — no screen recording.
The same pipeline supports single-feature demos, workflow explainers, launch
videos, and full-release roundups. This directory holds only the current Hunk
storyboard: the capture scenes (`capture.ts`) and the shot list, cards, and
captions (`compose.mjs`). The generic machinery — PTY driving, keyframe
rendering, storyboard planning, Chromium compositing, and the stage template —
is the `@hunk/term-video` package in `packages/term-video/`.

The full procedure — regeneration steps, environment gotchas, scene authoring,
storyboard model, and content-accuracy rules — lives in
[`skills/hunk-launch-video/SKILL.md`](../../skills/hunk-launch-video/SKILL.md).

`promo/` is the house style for release videos: `engine.js` and
`stage.html` are the reusable style kit, `storyboards/<version>.mjs` holds one
release's content, `contributors.mjs` computes the credits from a tag range,
and `render.mjs` renders the cut through `@hunk/term-video/timeline`. The
full-release recipe in the skill walks through making the next one.

---

### Overview (`workers/release-proxy/README.md`)

# Hunk release proxy

This Cloudflare Worker serves `GET /v1/curl/latest`. Clients reach it as
`https://hunk.dev/api/release/latest`: the repository-level `vercel.json` rewrites that path to the
Worker's `updates.hunk.dev` custom domain so the installer and `hunk update` only ever contact the
apex domain. macOS 26.4+ runs browser-pasted Terminal commands in an XProtect sandbox and blocks
the paste when the command reaches a host Apple's website reputation service distrusts, which the
Worker hostname tripped. It normalizes GitHub's latest stable Hunk release to:

```json
{ "version": "0.20.1" }
```

A cron trigger refreshes that version in Workers KV every minute. Requests read KV instead of
GitHub, so release-check traffic cannot consume GitHub's API quota. Failed refreshes preserve the
last valid version, while metadata older than six hours returns an endpoint error so Hunk can use
its direct-GitHub fallback. Unchanged metadata writes a fresh heartbeat at most once per hour.

The Worker writes one structured `release_check` log containing only allowlisted `source` and
`currentVersion` values. Scheduled attempts write a bounded `release_refresh` result. Client
responses use `Cache-Control: no-store` so Cloudflare's outer cache cannot bypass the Worker and its
per-request log. It does not use cookies, request bodies, or installation identifiers. Cloudflare's
infrastructure may provide its own request metadata subject to the account's log and retention
configuration.

## Development

```sh
npm install
bun test
npm run typecheck
npm run dev
```

## Deployment

The committed `RELEASE_METADATA` binding points at the production KV namespace. Before the first
deployment, add a read-only GitHub credential as a Worker-scoped secret:

```sh
npx wrangler secret put GITHUB_TOKEN
```

`wrangler deploy` publishes the Worker and its every-minute cron trigger to the configured
`updates.hunk.dev` custom domain. The `release-proxy.yml` workflow checks pull requests and `main`
without receiving production credentials; deployment stays a manual operation from a trusted
maintainer machine:

```sh
npm ci
npm test
npm run typecheck
npm run deploy
curl -fsS https://updates.hunk.dev/v1/curl/latest
curl -fsSL https://hunk.dev/api/release/latest
```

The second check covers the Vercel rewrite. Keep the `updates.hunk.dev` custom domain while
releases that call it directly remain installed; they fall back to GitHub when it is unavailable.

The client and installer fall back directly to GitHub, so their rollout does not depend on
deployment ordering. Verify the endpoint and its bounded structured logs after each deployment.
Keep the GitHub credential in the Worker secret, never in Hunk or Wrangler configuration.

---

### Overview (`examples/extensions/cli-tools/README.md`)

# CLI tools extension

Demonstrates a generic top-level command tree. The handler owns every token below `cli-tools`, can stream headless output, receives cooperative cancellation, and can hand terminal ownership to one built-in Hunk command.

Run it directly from this checkout:

```bash
bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools status
bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools review
```

`status` writes to stdout and exits. `review` performs signal-aware asynchronous preprocessing, writes progress to stderr, then delegates to `hunk diff`. A delegating handler must not write stdout or read stdin because the built-in command or TUI takes ownership of both.

The example parses its raw argument tokens directly. Extensions may use their own parser, make network requests, spawn processes, or access the filesystem under Hunk's ordinary extension trust model.

---

### Overview (`examples/extensions/code-document-file-view/README.md`)

# Code-document file view

This API-v28 example presents one old/new code pair per diff hunk. It declares the complete source documents so Hunk can tokenize with multiline lexical context, while each displayed span references either a complete old-side line or a partial new-side UTF-16 slice. Gutters and the unhighlighted prefix stay ordinary symbolic spans.

Hunk owns the language, active syntax theme, workers, caching, terminal safety, and plain fallback. The extension never supplies token colors.

Run it from this checkout against two JavaScript or TypeScript files:

```bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/code-document-file-view \
  path/to/before.ts path/to/after.ts
```

Choose **View → File presentation → Code documents: old / new**. The example intentionally omits `sourceRanges`: syntax references paint code but do not claim note/navigation ownership. A visible note therefore makes Hunk use the raw diff, as required by the all-or-raw note policy.

---

### Overview (`examples/extensions/inline-edit/README.md`)

# Inline edit extension

A miniature line editor for the file under review. Press `Ctrl-E`, type into the diff, press `Ctrl-S`, and Hunk writes the file back to your working tree after asking you first.

This example is **not bundled or loaded by Hunk**. Install it explicitly if you want it.

It exists to demonstrate that Hunk's interactive extension surfaces compose, so it uses them all at once:

| Capability                              | Where this extension uses it                                                                                                                                                                                                                                           |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.workspace`                         | `canWriteDocument` gates the affordance, `readDocument("new")` fills the buffer, `writeDocument` performs the consented write and reloads the review.                                                                                                                  |
| `mode` on a registered file view        | `onKey` claims arrows, editable characters, Backspace, Enter, and `Ctrl-S`, while `]`, `?`, and `q` pass through so navigation, help, and quit keep working. `Ctrl-S` is recognized with `matchesKey`, so the bare control byte terminals send for it matches too.     |
| `fileViews.enterMode(viewId)`           | One call makes the view the file's presentation _and_ gives its mode the keyboard, so `Ctrl-E` opens the editor in a single press.                                                                                                                                     |
| `fileViews.refresh(viewId, { fileId })` | Every buffer or caret change re-derives the layout. A view's layout is a pure function of `(file, width)`, so this is the only way a stateful presentation redraws — and the buffer belongs to one file, so the refresh is scoped to it and no other file re-lays out. |

## Try it from this checkout

```bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/inline-edit
```

## Install it globally

Copy the whole folder — it has no dependencies:

```bash
mkdir -p ~/.config/hunk/extensions
cp -R examples/extensions/inline-edit ~/.config/hunk/extensions/
```

Hunk discovers the folder automatically on later launches. Open **View** and choose **File presentation: Inline edit**, or press `Ctrl-E`. The command is named `inline-edit.edit` for `[keybindings]` customization.

## Keys

| Key                           | Does                                                                          |
| ----------------------------- | ----------------------------------------------------------------------------- |
| `Ctrl-E`                      | Starts editing the selected file, showing the view if it was not already.     |
| `↑` `↓` `←` `→`               | Move the caret. `←`/`→` wrap across line ends.                                |
| any other printable character | Types at the caret — including characters bound to Hunk commands, like `z`.   |
| `Backspace`                   | Deletes back one character, joining with the previous line at column 0.       |
| `Enter`                       | Splits the line at the caret.                                                 |
| `Ctrl-S`                      | Asks Hunk to write the buffer. Hunk confirms, writes, and reloads the review. |
| `Esc`                         | Leaves the editor and **discards** everything typed since the last write.     |
| everything else               | Still Hunk's: `]` moves to the next hunk, `?` opens help, `q` quits.          |

The header row reads `EDITING — Esc exits · ctrl+s writes`, plus a `MODIFIED` marker whenever the buffer differs from the text on disk.

## How the pieces fit

`Ctrl-E` is one press. The command gates on `canWriteDocument`, starts reading the document, and calls `fileViews.enterMode` before awaiting that read. Entry therefore uses the same selected file the command captured; a selection change cannot attach a late buffer to another file. `enterMode` makes the view that file's presentation and gives its mode the keyboard together, then the completed read builds the buffer and refreshes the view. When entry refuses (no `mode`, a file Hunk keeps on raw diff, a view that does not match), it warns by name and returns `false` without installing a buffer.

The editor slot is claimed synchronously, before that command's first `await`. Reading the document suspends the handler, so a guard that only checked "is a session live?" would let a second `Ctrl-E` through the window in between, and one of the two handlers would then be parked forever on a session nothing could end. The claim is released on every way out — an unwritable review, an unreadable document, a refused `enterMode` — and once a session is live it is the live session that answers the next press.

`onKey` has to answer synchronously — its return value _is_ the routing decision — and the mode context carries only `file` and `fileViews`. So a keystroke can never write anything itself. What `Ctrl-S` does instead is post a request into the edit session, and the command handler that entered the mode is still awaiting that session: `ctx.workspace` is valid for the whole life of a command handler's promise, so the handler is the mode's async runtime.

That loop ends the same way from every direction, because `onExit` runs on every exit path:

- **Saved.** A successful `writeDocument` reloads the session, the reload exits the mode, `onExit` ends the loop.
- **Escaped.** Escape is host-owned, so it exits the mode without ever reaching `onKey`, and the loop ends the same way.
- **Cancelled write.** `{ ok: false, reason: "cancelled" }` is the user answering, not a failure. The editor keeps running.
- **Failed write.** The `detail` sentence is shown as a warning and the editor keeps running, so nothing typed is lost to a full disk.

## Limitations

This is a demonstration, not an editor:

- **No wrapping.** Lines are truncated at the pane width with `…`. A file-view layout must be deterministic for `(file, width)`, and wrapping would be a second layout problem on top of the one this example is about.
- **Whole-line truncation.** The caret can move past the visible edge; the row does not scroll horizontally to follow it.
- **One buffer, no undo.** Escape discards everything since the last write, with no confirmation beyond the notice it leaves behind.
- **Writes are working-tree only**, which is a property of `ctx.workspace`: `hunk show`, `hunk patch`, a staged diff, and a file-pair diff have no working-tree document to replace, so the command refuses instead of opening an editor that could not save.
- **Agent notes describe the document as it was loaded.** They stay on the lines they were written about, but nothing re-reads the changeset while you type.

## Source bindings and provenance

Rows carry `sourceRanges` so Hunk can place its own inline notes inside this presentation, and a row may only bind a line it honestly still _is_. Row position cannot answer that once you split or join a line, so the edit session keeps **provenance**: for each buffer line, the document line it came from, or nothing at all.

| Edit               | Provenance                                                               |
| ------------------ | ------------------------------------------------------------------------ |
| typing in a line   | kept — an edited line is still the line it came from                     |
| `Enter` (split)    | the first line keeps it; the new tail gets none                          |
| `Backspace` (join) | the merged line presents both source lines so either note stays attached |

So a line you inserted binds nothing, the lines below a split keep the numbers they had, and a joined row keeps every source line it now presents. The hunk extents in `hunkRows` are derived from the same provenance, so the hunk highlight follows the rows still holding a hunk's lines instead of drifting down by however many lines you added above them. A join across two distinct hunks is refused because one bound row cannot belong to two hunk extents.

One thing this shows that is easy to get wrong: Hunk accepts a binding only on a row exactly one `hunkRows` extent owns, and rejects the whole layout otherwise — so a last pass drops the bindings no extent owns, and context lines outside every hunk are presented without one. The [rendered Markdown example](../rendered-markdown/) ends its layout with the same pass, for the same reason.

## Where this is documented

- [`docs/extensions.md` → Interactive file views](../../../docs/extensions.md#interactive-file-views)
- [`docs/extensions.md` → Reading and writing a reviewed file](../../../docs/extensions.md#reading-and-writing-a-reviewed-file)
- [`docs/extensions.md` → `hunk.registerFileView(view)`](../../../docs/extensions.md#hunkregisterfileviewview-experimental)

---

### Overview (`examples/extensions/jsx-file-view-gallery/README.md`)

# JSX file-view gallery

Three opt-in presentations exercise the constrained React/OpenTUI row contract against checked-in, realistic file pairs. Run one command from the repository root, then press **F8** or choose **Extensions → Toggle JSX demo for current file**. Press F8 again to restore Pierre's raw diff.

## 1. Change atlas

Nested boxes, responsive meters, semantic color, and selected-hunk styling summarize a multi-hunk TypeScript refactor. It uses no source parser and works from public hunk/change metadata alone.

```bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/jsx-file-view-gallery \
  --mode unified \
  examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts \
  examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/after.ts
```

## 2. CSS palette delta

The extension lazily reads both exact documents, associates changed opaque three- or six-digit hexadecimal custom properties with each real diff hunk, and paints old/new terminal color swatches inside deterministic two-row rectangles.

```bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/jsx-file-view-gallery \
  --mode unified \
  examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css \
  examples/extensions/jsx-file-view-gallery/fixtures/css-palette/after.css
```

## 3. Dependency delta

A conservative package-file parser highlights only the changed semantic-version segment: patch-only changes emphasize the patch number, minor upgrades emphasize the minor number, and major upgrades emphasize the full old/new strings. It retains positional bounds for every parsed hunk; invalid JSON or unavailable source falls back to raw diff.

```bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/jsx-file-view-gallery \
  --mode unified \
  examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json \
  examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/after/package.json
```

## Mixed five-file review

To see all three preview types retained together between ordinary raw diffs—and enough content to exercise stream scrolling—run:

```bash
bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.ts
```

Follow the short activation sequence in [`mixed-review/README.md`](./mixed-review/README.md).

## Contract illustrated

- Every painter stays inside a declared fixed-height row; geometry, scrolling, windowing, and hunk navigation remain host-owned.
- Every row keeps useful symbolic spans for row-local error fallback, clipped to the same fixed rectangle.
- Semantic data is captured in closures during `layout`; painters receive only bounded paint props and use Hunk's live paint-only semantic theme palette.
- The demos intentionally have no pointer handlers. Registered commands are the supported interaction path.
- Layouts return `null` when exact source is unavailable or no supported semantic row can be attributed. Mixed diffs may retain neutral summary rows for non-semantic hunks so navigation stays positional.
- Rows bind conservatively attributed exact-source ranges. Hunk renders a note inside the alternate view only when its preferred-side anchor resolves uniquely; otherwise the complete file falls back to raw diff.

This gallery is experimental and is not loaded unless you explicitly pass or install its folder. See [`docs/file-view-jsx-poc.md`](../../../docs/file-view-jsx-poc.md) for the full contract.

---

### Overview (`examples/extensions/jsx-file-view/README.md`)

# JSX file-view POC extension

An opt-in proof of concept for fixed-height React/OpenTUI rows in alternate file presentations. It appears only for files with at least two parsed hunks and creates two custom rows per hunk, with stable row IDs and explicit hunk bounds.

Run it from this checkout against a multi-hunk working-tree change:

```bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view
```

Choose **Extensions → Toggle JSX hunk cards (POC)**. The row component uses a React state hook and OpenTUI `box`/`text` elements. Its registered F8 command/menu item is the supported keyboard path. A cooperatively delivered, un-dragged left-button mouse-up toggles local detail and stops propagation; wheel, drag, and unhandled input remain host-owned. The row is a non-focusable paint surface, with no portal, renderer, focus, or input-delivery guarantee. Each component is a closure over the hunk summary; Hunk passes it only bounded paint props, including a live semantic theme palette that does not participate in layout. The `spans` on every row are the host-rendered fallback, clipped to the same declared fixed height if the component fails. Hook state survives selected-hunk updates while mounted, but is intentionally lost when windowing unmounts the row or a new layout generation replaces it.

This example is deliberately opt-in and experimental. See [`docs/file-view-jsx-poc.md`](../../../docs/file-view-jsx-poc.md) for constraints.

---

### Overview (`examples/extensions/pane-layout/README.md`)

# Pane layout extension

Registers a resizable right pane and fixed two-row top and bottom panes.

Run it from this checkout:

```bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/pane-layout
```

Press `ctrl+p` or use the **Extensions** menu. Drag the right divider to resize.

Copy this directory to your Hunk extensions directory to install it.

---

### Overview (`examples/extensions/rendered-markdown/README.md`)

# Rendered Markdown extension

An optional Markdown preview for Hunk's experimental file-view API. It parses Markdown with [Marked](https://marked.js.org/) and presents headings, inline formatting, links, lists, quotes, tables, and fenced code as host-owned symbolic rows.

This example is **not bundled or loaded by Hunk**. Install it explicitly if you want it.

## Try it from this checkout

The repository's root install supplies the example's development dependency:

```bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/rendered-markdown \
  before.md after.md
```

## Install it globally

Copy the whole folder, then install its local dependency:

```bash
mkdir -p ~/.config/hunk/extensions
cp -R examples/extensions/rendered-markdown ~/.config/hunk/extensions/
cd ~/.config/hunk/extensions/rendered-markdown
bun install
```

Hunk discovers the folder automatically on later launches. Open **View** and choose **File presentation: Rendered Markdown**, or press `F8`. The command is named `rendered-markdown.toggle-rendered-markdown` for `[keybindings]` customization.

Raw Pierre diff remains the default and fallback. The preview reads exact text through `input.readDocument`, returns hunk row bounds in source-hunk order, binds rendered blocks to exact new-side ranges, and retains hunk navigation and selection highlighting. Hunk inserts inline note cards at uniquely bound rows; if any visible note cannot be resolved, the complete file temporarily returns to raw rendering so review data is never hidden.

## Why it returns rows instead of an OpenTUI Markdown component

OpenTUI includes a capable `MarkdownRenderable`, but mounting it would make the extension own opaque layout geometry. Hunk needs exact rows before mount for review-stream measurement, windowing, scrolling, hunk navigation, and fallback. This example therefore uses the same Marked parser OpenTUI uses, then translates its tokens into the file-view contract's generic tones and text attributes.

---

### Overview (`examples/extensions/review-note-navigator/README.md`)

# Review note navigator extension

Lists every note currently saved in Hunk's shared ReviewStore, then navigates to a selected visible note's authoritative source anchor. The example shows how `ctx.review.snapshot()` complements dialogs and live navigation: selection and note events alone cannot recover notes that already existed before the extension began listening.

Run it directly from this checkout:

```bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator
```

Save one or more review notes, then run **Extensions → Navigate saved review note…** (`F8`). Each choice includes its reconciliation status, file, preferred line, side, and summary.

- Active notes reveal their current source line.
- Stale notes reveal the last authoritative anchor Hunk retained.
- Orphaned notes remain visible in the inventory but produce a warning because they have no current review location.
- A note whose file is hidden by Hunk's current file filter remains in the inventory, but guarded navigation refuses the hidden target with a warning; clear the filter and retry.
- Drafts and static sidecar annotations that never entered ReviewStore are intentionally absent.

The command captures the complete note inventory before opening its selector. After the user chooses, it reads the authoritative snapshot again and resolves the selected note by stable note id, so an edit made by another review surface cannot make it navigate using an obsolete anchor. A reload cancels the dialog and retires the command's review controls.

---

### Overview (`examples/extensions/review-snapshot-export/README.md`)

# Review snapshot export extension

Exports Hunk's authoritative saved review state as JSON. The example shows why `ctx.review.snapshot()` is more useful than accumulating `note_created` events: one command receives every note currently retained by the shared ReviewStore, including stale and orphaned notes an exporter must handle explicitly.

Run it directly from this checkout:

```bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export
```

Add one or more review notes, then run **Extensions → Export review snapshot…** (`F9`) and choose a new output path. Relative paths resolve from the review's working directory; the example refuses to overwrite an existing file.

The JSON includes:

- the opaque producer generation and ReviewStore revision
- every reviewed file's stable `fileKey`, runtime navigation id, content identity, status, and path
- all saved live and reviewer notes with their resolved old/new anchors and reconciliation status

Draft notes and static sidecar annotations that never entered ReviewStore are intentionally absent. Saved notes appear in live-arrival order followed by reviewer-creation order.

The command captures a snapshot before opening its path dialog, then reads the controls again before writing. If either the generation or revision changed, it refuses the stale export and asks the user to rerun the command. Publishers can use the same check before an irreversible network request.

## Trust

This example uses `node:fs` directly to write the user-selected export path. Hunk extensions run with the user's full permissions; this is distinct from `ctx.workspace`, which mediates writes to reviewed files and asks for consent. Install and run only extensions you trust.

---

### Overview (`examples/extensions/review-triage/README.md`)

# Review triage extension

A session-local hunk review board for Hunk. It records which hunks you have visited and lets you mark the selected hunk **approved**, **investigate**, or **blocked** with an optional rationale.

Run it directly from this checkout:

```bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-triage
```

Or copy the directory to your Hunk extensions directory and keep its `package.json`; its manifest makes the folder a single `review-triage` extension.

## Use

Open **Extensions → Toggle review triage** (`Y`). The right pane lists each visible file's hunks; click a hunk to navigate the review stream. Use **Extensions → Mark selected hunk…** (`x`) to choose a status and enter an optional rationale. **Center current review line**, **Set review focus…**, and **Clear triage decisions** are menu-only commands.

The board intentionally keeps state only for the running Hunk session. Reloading reconciles decisions against the newly parsed hunks and drops entries that no longer match, rather than silently transferring a decision to changed code.

## API surface exercised

- `registerPane` renders public file/hunk summaries and navigates with pane actions.
- `registerCommand` supplies the Extensions-menu items and user-remappable defaults; `ctx.commands.execute` delegates the dedicated centering action to Hunk's public semantic command.
- `dialogs.select`, `dialogs.input`, and `dialogs.confirm` implement the review decision and clear flows.
- Lifecycle handlers track changeset loads, reloads, selection, viewed hunks, Hunk notes, filters, and pending watch reloads through a `useSyncExternalStore` bridge.
- `hunk.events` publishes decisions and listens for `review-triage:open`, so another extension can reveal the board without importing its state.

---

### Overview (`examples/extensions/vim-navigation/README.md`)

# Vim navigation extension

A small Vim-style normal mode for Hunk's whole review stream. It demonstrates session keyboard modes and public semantic command execution without accessing scroll boxes, renderer objects, or viewport coordinates.

This example is **not bundled or loaded by Hunk**. Install it explicitly if you want it.

## Try it from this checkout

```bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/vim-navigation
```

Press `F6` or choose **Extensions → Toggle Vim navigation**. The persistent status badge shows when the mode owns review-level keys; click the badge, choose the host-owned exit menu item, or press `Esc` to leave.

## Install it globally

```bash
mkdir -p ~/.config/hunk/extensions
cp -R examples/extensions/vim-navigation ~/.config/hunk/extensions/
```

## Keys

| Key                 | Action                                                      |
| ------------------- | ----------------------------------------------------------- |
| `j` / `k`           | Move the current review line down/up                        |
| `[` / `]`           | Move to the previous/next hunk                              |
| `gg` / `G`          | Jump to the start/end of the review                         |
| `zt` / `zz` / `zb`  | Align the current line at the top/center/bottom             |
| `Ctrl-D` / `Ctrl-U` | Move down/up by half pages                                  |
| positive digits     | Prefix the next relative motion, for example `5j` or `3]`   |
| `:`                 | Open the host-rendered Vim command line                     |
| `Esc`               | Exit the mode (host-owned; the extension never receives it) |
| everything else     | Pass through to normal Hunk routing                         |

Counts are parsed by the extension and capped at 10,000. Once a normal-mode sequence resolves, the extension calls `ctx.commands.execute(id, { count })` exactly once, so Hunk applies movement atomically. A bare `0` passes to Hunk's normal layout shortcut; `0` can extend a count that already began with `1`–`9`.

Pressing `:` passes the key to the example's registered command, which opens `ctx.prompts.line()` on the status row with a `:` prefix. That focused host input captures typed keys ahead of the still-active session mode until Enter submits, or Escape clears the buffer and a second Escape cancels. The deliberately small Ex-style command set is:

| Command   | Action                          |
| --------- | ------------------------------- |
| `:top`    | Jump to the start of the review |
| `:bottom` | Jump to the end of the review   |

Unsupported commands produce an attributed warning. Absolute source-line commands such as Vim's `:100` are intentionally absent because Hunk does not expose source-line targeting as a public semantic command; relative counted movement such as `100j` remains available in normal mode.

The example enables Hunk's host-owned current-line marker on entry so the `z*` alignment commands have a target. It resets all pending prefix/count state on entry and exit. Invalid continuations clear pending state and pass the current key back to Hunk.

---

### Overview (`test/cli/install-vm/README.md`)

# Optional Firecracker install compatibility suite

This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, authenticated daemon upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets.

## Run

Requirements:

- Linux x86_64 with at least 6 GiB free;
- Docker daemon access without `sudo`;
- readable/writable `/dev/kvm` and `/dev/net/tun`.

```sh
bun run test:install-vm -- --list
bun run test:install-vm -- --scenario pnpm-global-upgrade
bun run test:install-vm -- --scenario authenticated-daemon-upgrade
bun run test:install-vm
```

For manual investigation, open a root shell in a clean Ubuntu 24.04 guest. Every shell includes curated examples and deterministic benchmark-shaped patches under `/root/fixtures`. Add `--with-hunk` to build the current checkout and install its Linux x64 baseline binary and bundled skills before the shell opens:

```sh
bun run vm:shell
bun run vm:shell -- --with-hunk
```

The shell starts in `/root`, so `hunk patch fixtures/examples/2-mini-app-refactor/change.patch` opens a small multi-file example and `hunk patch fixtures/benchmarks/balanced-changeset.patch` opens a larger synthetic review. See `/root/fixtures/README.md` for the complete command list. The default command stays pristine for install and upgrade testing: it does not build package fixtures, stage Hunk, or mount the checkout. Both modes require an interactive terminal and reuse the pinned kernel, Firecracker binary, and prepared Node rootfs cache. The guest has 2 vCPU, 2 GiB RAM, public-internet egress, and Node under `/opt/node`; controller-enforced filtering rejects spoofed guest sources and private, link-local, metadata, and reserved destinations. Both modes mount one owner-only staging directory containing only allowlisted example inputs and generated benchmark patches; `--with-hunk` adds the freshly compiled binary and generated `hunkdiff/skills` tree to that snapshot. The controller copies the snapshot into the disposable guest and the host removes the staging directory after the session. Exit the SSH shell to stop Firecracker and delete the writable disk, ephemeral SSH key, socket, configuration, TAP device, and firewall rules. The immutable base cache remains for later sessions. The shell and install suite share a runtime lock and cannot run concurrently.

Use `--reuse-fixtures` to reuse package fixtures only when their checkout identity and every tarball checksum still match; stale or altered fixtures are rebuilt. Automation that intentionally permits unsupported hosts may pass `--allow-skip`; a requested local run otherwise fails with an actionable preflight report. In GitHub Actions, an allowed skip emits a workflow warning and a prominent step summary in addition to a structured skipped result—it is not VM success.

The first run lazily builds the controller image and downloads checksum-pinned Firecracker, kernel, rootfs, and Node inputs. They live under `tmp/install-vm/cache`; generated package fixtures and structured runs live under `tmp/install-vm/fixtures` and `tmp/install-vm/runs`. Remove only those harness-owned artifacts with:

```sh
bun run test:install-vm:clean
```

The runner deliberately does not reclaim a stale `tmp/install-vm/.lock`, because deleting a lock
owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and
remove that lock directory manually before retrying.

Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Fixture preparation also compiles two full Hunk binaries from isolated copies of the exact checkout and reflink-capable dependency snapshots: the current authenticated daemon revision and the immediately preceding incompatible revision. The checkout `sourceIdentity` remains source-only; a separate `daemonUpgradeBuildInputIdentity` frames and hashes the ignored `node_modules` snapshot bytes and contained symlink targets plus the Bun executable bytes/version used by the builder. External dependency symlinks are rejected, and an isolated PATH entry forces nested build commands to use that attested Bun executable. The fixture manifest binds both compiled binary SHA-256 digests, which the guest compares with the live A/B daemon executables. Temporary source, dependency, and package staging trees are removed before fixtures become visible. The daemon-upgrade scenario therefore adds two compilation passes and about one production idle timeout to a targeted run. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Scenario-specific required-evidence contracts bind required command IDs to exact expectation semantics and make missing daemon lifecycle proof fail aggregation and release validation. The daemon scenario stops one exact test-owned upgraded client across incumbent retirement, waits for the other original client to register on the successor, then resumes and verifies the delayed PID/start-token registers without restart; it never signals a daemon. Release validation rejects symlinked or escaping evidence and reads the referenced health, metadata, executable, fixture-manifest, warning, command-log, and session-list artifacts from the run directory instead of trusting result labels alone. It also requires the locally reusable fixture set to pass checkout, build-input, and package checksum verification, then extracts and hashes the actual A/B package binaries as an independent digest trust input; missing or stale local fixtures fail validation. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts <result.json>`. A targeted development run can be checked with `--scenario <id>`; that explicit subset check is not complete release evidence.

## Security boundary

The controller container receives only `/dev/kvm`, `/dev/net/tun`, `NET_ADMIN`, `CHOWN`, and `DAC_OVERRIDE`. The last two let it traverse the owner-only validated cache/result binds while running, then return their ownership to the invoking user; the directories are never made world-writable. The container drops all other capabilities, enables `no-new-privileges`, uses a read-only container root, and never mounts the repository or Docker socket. TAP and NAT changes stay in its Docker network namespace and are removed on exit. Third-party package lifecycle scripts run as root only inside disposable guests with no repository, host credentials, or host-writable package cache.

This is development/test isolation, not a production Firecracker jail. Run repository-controlled KVM jobs only on trusted disposable hosts. The interactive shell has filtered public-internet egress and should not receive host secrets; its controller mounts only the harness cache and the validated shell-input snapshot. The dedicated workflow is manual and never runs for pull requests.

## Coverage boundaries

Firecracker runs Linux guests on the host CPU. It cannot validate macOS or Windows, emulate Apple Silicon, or reproduce the final native macOS ARM64 exit behavior from issue #866. Native Apple Silicon coverage remains tracked by `TODO-1994d3d9`.

The Node-resolvable npm Bun fallback remains a best-effort legacy path. A standalone `bun` on PATH is deliberately different and is not used by the launcher. The suite observes an older fallback package but does not declare a supported minimum Bun version.

---

### Overview (`examples/extensions/jsx-file-view-gallery/mixed-review/README.md`)

# Mixed preview review

This launches one real five-file Git working-tree review that is intentionally taller than a terminal viewport:

- `README.md` — raw Markdown diff;
- `package.json` — dependency version-segment highlights;
- `scripts/deploy.py` — raw Python diff;
- `src/invoice.ts` — responsive change-atlas cards;
- `styles/theme.css` — exact-source color swatches.

The launcher creates a temporary repository, commits the checked-in `before` fixtures, copies the `after` fixtures into its working tree, and starts Hunk from this checkout. The repository is removed when Hunk exits.

From the Hunk repository root:

```bash
bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.ts
```

Raw diff is deliberately the default. To build the mixed stream:

1. Click `package.json` in the sidebar and press **F8**.
2. Click `src/invoice.ts` and press **F8**.
3. Click `styles/theme.css` and press **F8**.
4. Click `README.md` to return to the top, then scroll through the main pane.

Selecting files only jumps the main review stream; it does not collapse other files. The three preview selections therefore remain active together, interleaved with the two raw Pierre diffs. Use `[` and `]` while scrolling to verify that hunk navigation crosses raw and custom sections using the same host-owned geometry.

---

### Website Src Content Docs Changelog Index (`website/src/content/docs/changelog/index.md`)

---
title: Changelog
editUrl: false
description: "Every Hunk release, newest first — features, fixes, and performance work in the terminal diff viewer."
tableOfContents: false
head:
  - tag: meta
    attrs:
      property: og:image
      content: "https://hunk.dev/changelog/og/index.png"
  - tag: meta
    attrs:
      property: og:image:alt
      content: "Hunk changelog — 24 release series · March 2026 – October 2026"
  - tag: meta
    attrs:
      name: twitter:image
      content: "https://hunk.dev/changelog/og/index.png"
---



[RSS](https://hunk.dev/changelog/rss.xml) · [CHANGELOG.md](https://github.com/modem-dev/hunk/blob/main/CHANGELOG.md)

## [Hunk 0.24](/changelog/0.24/)

Latest · October 13, 2026 – October 19, 2026 · 3 releases · 119 changes

Hunk 0.24 adds complete-file and directory browsing, a live workspace status view, npm-installed extensions, shell completions, and pane-aware automatic diff layout, while moving syntax highlighting off the review's main thread by default.

## [Hunk 0.23](/changelog/0.23/)

September 30, 2026 · 1 release · 18 changes

Hunk 0.23 brings diff search, bundled GitHub review, an inline status line, and a default theme that follows your terminal, while making daemon version mismatches easier to resolve.

## [Hunk 0.22](/changelog/0.22/)

September 8, 2026 – September 10, 2026 · 3 releases · 81 changes

Hunk 0.22 turns repository history into a responsive review workspace with commit-range browsing, persistent multiline selections, keyboard-driven threaded notes, and richer extension context.

## [Hunk 0.21](/changelog/0.21/)

August 30, 2026 – September 4, 2026 · 4 releases · 77 changes

Hunk 0.21 turns reviews into a richer agent-aware workspace with authenticated live sessions, editable threaded notes, two-revision and two-file comparisons, more responsive navigation, and a broader extension surface.

## [Hunk 0.20](/changelog/0.20/)

August 25, 2026 – August 29, 2026 · 2 releases · 14 changes

Hunk 0.20 adds a one-line installer with conditional checksum verification and self-updates, launches a directory of 12 community extensions, and makes line-level reviewing faster and more precise.

## [Hunk 0.19](/changelog/0.19/)

August 16, 2026 – August 24, 2026 · 2 releases · 39 changes

Hunk 0.19.0 expands the extension platform, makes live agent guidance more precise, and keeps large reviews responsive.

## [Hunk 0.18](/changelog/0.18/)

August 1, 2026 – August 14, 2026 · 4 releases · 90 changes

Hunk 0.18.0 makes reviews more precise, customizable, and extensible—while improving performance and reliability across large repositories and diverse terminals.

## [Hunk 0.17](/changelog/0.17/)

July 7, 2026 – July 28, 2026 · 8 releases · 26 changes

Hunk 0.17 makes the review chrome configurable, adds an Agent menu for setting up the review skill, and smooths out scrolling, theming, and session reliability across platforms.

## [Hunk 0.16](/changelog/0.16/)

June 19, 2026 · 1 release · 8 changes

Hunk 0.16 rebuilds the built-in theme system around a single theme setting with a live selector, and lets custom themes inherit from any built-in theme.

## [Hunk 0.15](/changelog/0.15/)

June 8, 2026 – June 13, 2026 · 4 releases · 23 changes

Hunk 0.15 adds release benchmark gating, Sapling support, and a run of rendering performance work that keeps large reviews responsive on Windows and Unix alike.

## [Hunk 0.14](/changelog/0.14/)

May 26, 2026 – June 1, 2026 · 2 releases · 20 changes

Hunk 0.14 adds mouse-drag selection with clipboard copy, inline expansion of collapsed unchanged context, and the Catppuccin Latte and Mocha themes.

## [Hunk 0.13](/changelog/0.13/)

May 18, 2026 – May 19, 2026 · 2 releases · 12 changes

Hunk 0.13 adds human-authored inline notes saved with c, an e shortcut to open the selected file in $EDITOR, and g/G jump navigation.

## [Hunk 0.12](/changelog/0.12/)

May 12, 2026 – May 14, 2026 · 2 releases · 15 changes

Hunk 0.12 brings native Windows support, Homebrew release automation, terminal-aware light and dark theme detection, and row windowing for very large single-file reviews.

## [Hunk 0.11](/changelog/0.11/)

May 9, 2026 – May 10, 2026 · 2 releases · 13 changes

Hunk 0.11 adds Jujutsu support through vcs = "jj", a pager-mode sidebar file tree, and steadier cross-file hunk navigation.

## [Hunk 0.10](/changelog/0.10/)

April 21, 2026 · 1 release · 7 changes

Hunk 0.10 introduces hunk daemon serve as the standard session entrypoint, publishes reusable session-broker packages, and surfaces agent comment counts in the sidebar.

## [Hunk 0.9](/changelog/0.9/)

April 8, 2026 – April 21, 2026 · 6 releases · 28 changes

## [Hunk 0.8](/changelog/0.8/)

March 29, 2026 – March 30, 2026 · 2 releases · 11 changes

## [Hunk 0.7](/changelog/0.7/)

March 25, 2026 · 1 release · 8 changes

## [Hunk 0.6](/changelog/0.6/)

March 23, 2026 – March 24, 2026 · 2 releases · 11 changes

## [Hunk 0.5](/changelog/0.5/)

March 22, 2026 – March 23, 2026 · 2 releases · 10 changes

## [Hunk 0.4](/changelog/0.4/)

March 22, 2026 · 1 release · 2 changes

## [Hunk 0.3](/changelog/0.3/)

March 22, 2026 · 1 release · 8 changes

## [Hunk 0.2](/changelog/0.2/)

March 20, 2026 · 1 release · 1 change

## [Hunk 0.1](/changelog/0.1/)

March 20, 2026 · 1 release · 7 changes

---

### Website Src Content Docs Docs Index (`website/src/content/docs/docs/index.mdx`)

---
title: Hunk documentation
description: Find the Hunk guide, workflow, configuration, or reference you need.
---

import { CardGrid, LinkCard } from "@astrojs/starlight/components";

Hunk is a terminal diff viewer for reviewing complete changesets and keeping agent rationale beside the code it explains. Use this page to jump directly to the guide or reference you need.

## New to Hunk?

1. [Install Hunk](/docs/start/install/).
2. Open your working tree with `hunk diff`.
3. Follow the [quick start](/docs/start/quick-start/) to learn the review stream and navigation model.

## Browse the docs

<CardGrid>
  <LinkCard
    title="Start here"
    href="/docs/start/install/"
    description="Install Hunk, open your first review, and learn the keyboard and mouse controls."
  />
  <LinkCard
    title="Review workflows"
    href="/docs/workflows/working-trees-and-commits/"
    description="Review working trees, commits, files, patches, pager input, difftool pairs, and other VCS backends."
  />
  <LinkCard
    title="Working with agents"
    href="/docs/agents/review-with-an-agent/"
    description="Let an agent inspect a live review, navigate hunks, and leave inline comments and annotations."
  />
  <LinkCard
    title="Configure"
    href="/docs/configure/configuration/"
    description="Set layered preferences, choose themes, and control layout and display behavior."
  />
  <LinkCard
    title="Extend"
    href="/docs/extend/extensions/"
    description="Write TypeScript extensions that add file previews, themes, VCS backends, transforms, commands, and sidebars."
  />
  <LinkCard
    title="Reference"
    href="/docs/reference/cli/"
    description="Look up generated CLI and config references or embed Hunk's OpenTUI components."
  />
  <LinkCard
    title="Help"
    href="/docs/help/troubleshooting/"
    description="Troubleshoot startup, sessions, and rendering, or check terminal and platform support."
  />
</CardGrid>

## Common tasks

- [Review current work or a commit](/docs/workflows/working-trees-and-commits/)
- [Configure Hunk as a Git pager or difftool](/docs/workflows/git-pager-and-difftool/)
- [Review a live session with a coding agent](/docs/agents/review-with-an-agent/)
- [Choose a theme](/docs/configure/themes/)
- [Remap a keyboard shortcut](/docs/configure/keybindings/)
- [Build a custom file preview](/docs/extend/file-previews/)
- [Look up a command](/docs/reference/cli/)
- [Troubleshoot terminal behavior](/docs/help/troubleshooting/)

---

### Agent Workflows (`docs/agent-workflows.md`)

# Agent workflows

Use Hunk with agents in two ways:

- **Recommended:** steer a live Hunk window from another terminal with `hunk session ...`
- **Alternative:** load prewritten agent notes from a file with `--agent-context`

## Recommended workflow: steer a live Hunk window

1. Open Hunk in one terminal with a normal review command such as `hunk diff` or `hunk show`.
2. Load the Hunk review skill: [`packages/hunk/skills/hunk-review/SKILL.md`](../packages/hunk/skills/hunk-review/SKILL.md).
3. Ask the agent to use the skill and review the current session.

A good generic prompt is:

```text
Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path.
```

That skill teaches the agent how to inspect a live Hunk session, navigate it, reload it, and leave inline comments.

## How live session control works

When a Hunk TUI starts, it registers with a local loopback daemon. `hunk session ...` talks to that daemon to find the right live window and control it.

Most users only need `hunk session ...`. Use `hunk mcp serve` only for manual startup or debugging of the local daemon.

If `hunk session list` reports no sessions while Hunk is visibly running, the agent sandbox may be blocking loopback access. Rerun `hunk session list --json` with the agent's network/sandbox escalation. Do not probe `/session-api` with raw `curl`: session controls require an automatically discovered, owner-private caller credential and signed responses, and Hunk intentionally exposes no credential flags.

## The commands you will use most

### Inspect the current review

Start here before navigating or commenting:

```bash
hunk session list
hunk session get --repo .
hunk session review --repo . --json
```

- `list` shows the active Hunk windows
- `get --repo .` confirms which live session matches the current repo
- `review --json` returns the loaded file and hunk structure without dumping the full raw patch

When a CLI extension delegated the review, JSON list, context, and review outputs may also include a
bounded `review` descriptor with provider, title, URL, and kind-specific identity. It is descriptive
context only and does not add remote provider or reload capabilities.

Only add `--include-patch` when an agent truly needs raw unified diff text:

```bash
hunk session review --repo . --include-patch --json
```

### Move the live window to the right place

Use `navigate` to jump to the file or hunk you want the user to see:

```bash
hunk session navigate --repo . --file src/App.tsx --hunk 2
hunk session navigate --repo . --next-comment
```

To jump to an exact comment returned by the JSON list, copy its `commentId` into `--comment`:

```bash
hunk session comment list --repo . --json
hunk session navigate --repo . --comment <comment-id> --json
```

Use `reload` when you want the already-open Hunk window to show a different diff or commit:

```bash
hunk session reload --repo . -- diff
hunk session reload --repo . -- show HEAD~1 -- README.md
```

Notes:

- always include `--` before the nested Hunk command in `reload`
- `--hunk` is 1-based
- `--next-comment` and `--prev-comment` are handy when an agent is walking the user through existing notes

### Add comments

For one note, use `comment add`:

```bash
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording"
```

Reply to an existing note by id; the reply inherits the parent's file and code anchor:

```bash
hunk session comment add --repo . --reply-to user:123 --summary "Addressed in the latest revision"
```

For multiple notes or replies, use one stdin batch with `comment apply`:

```bash
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"},{"replyTo":"user:123","summary":"Addressed"}]}' \
  | hunk session comment apply --repo . --stdin
```

Each `comment apply` item requires `summary` and either:

- `replyTo` by itself to inherit an existing note's anchor, or
- `filePath` with exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`

If you want the UI to jump to the new note, add `--focus` to `comment add` or `comment apply`.

For comment cleanup and inspection, use:

```bash
hunk session comment list --repo .
hunk session comment rm --repo . <comment-id>
hunk session comment clear --repo . --file README.md --yes
hunk session comment clear --repo . --all --yes # also clears human `c` notes
```

Agents can remove or bulk-clear human notes for cleanup, but cannot create or edit them through the session CLI.

## Session targeting

Most commands can target the live session in a few ways:

- `--repo <path>`: most common; matches the live session by its current repo root
- `<session-id>`: useful when multiple Hunk windows are open for the same repo
- if only one session exists, Hunk can auto-resolve it

`reload` also supports some advanced selectors:

- `--session-path <path>` targets the live Hunk window by its current working directory
- `--source <path>` changes where the replacement `diff` or `show` command runs

For normal worktree use, prefer `--repo /path/to/worktree`. Reach for `--session-path` and `--source` only when you need to repoint an already-open window to another checkout or path.

## Alternative workflow: load agent comments from a file

Use `--agent-context` when you already have agent-written rationale or notes in a JSON sidecar file and want to render them beside the diff.

```bash
hunk diff --agent-context notes.json
hunk patch change.patch --agent-context notes.json
```

For a compact real example, see [`examples/3-agent-review-demo/agent-context.json`](../examples/3-agent-review-demo/agent-context.json).

## Opt into experimental rich notes

STML note bodies are experimental and disabled by default. Start a new review with `--experimental` to render sidecar `markup` fields and accept live comments that carry markup:

```bash
hunk --experimental diff --agent-context notes.json
# Equivalent: hunk diff --experimental --agent-context notes.json
```

Normal reviews keep using each annotation's required plain-text `summary` fallback. Opted-in live sessions list `stml` in `hunk session context --json` under `experimentalFeatures`; reload commands cannot change the launch opt-in.

## Practical defaults

- start with `hunk session review --repo . --json`
- only add `--include-patch` when the raw patch is actually needed
- use `comment add` for one-off notes and `comment apply` for batches
- prefer `--repo` over `--session-path` unless you have a specific advanced reload case

---

### Brand (`docs/brand.md`)

# Hunk brand guide

Where Hunk's logo files live, which version to use where, and the colors and type the website is built on. Source of truth for the site look is `website/src/styles/brand.css`; this page records the intent.

## Voice and feel

- Terminal-first, "modern desktop diff tool in a terminal". Monospace everywhere, hard edges, paper-and-ink contrast with one green accent.
- Plain, concrete copy. Tagline: **Made for review.** Eyebrow: **Terminal diffs.** Domain: `hunk.dev`.
- The wordmark is always lowercase and monospace: `hunk`. That applies to the logo and to the header text next to the mark. Running prose, docs, and release notes keep their existing style (`Hunk` as the product name); this rule is about the logo, not copy.

## Public downloads

The public brand page at [hunk.dev/brand/](https://hunk.dev/brand/) offers all four logos in
ink and paper colors as SVG, transparent 2048px-wide PNG, and vector PDF, plus a ZIP bundle.
The website footer links to it.

The ink SVGs below remain the canonical sources. Regenerate the committed exports in
`website/public/brand/downloads/` and `website/public/brand/hunk-brand.zip` after editing them:

```bash
# Requires rsvg-convert (librsvg) and zip on PATH.
cd website
bun run generate:brand
```

Every logo is plain filled paths with no masks or strokes, so the PDF exports stay pure vector
(SVG masks export as embedded bitmaps). The generator recolors the ink fill and pins PDF dates,
so unchanged logos regenerate byte-for-byte. Downloads are static files;
the website build does not require the export tools.

## Logo versions

All files are in `website/public/brand/` and served at `https://hunk.dev/brand/<file>`. They are filled with the ink color `#16140f`; recolor by editing the `fill`, or inline the SVG and use `currentColor`.

| File                | What it is                                                                | Use it for                                                                           |
| ------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `logo-mark.svg`     | Flat "h" mark: a framed square with a stem and top-right bar. No outline. | Header, favicon, app icons, avatars, anything under ~40px.                           |
| `logo-mark-3d.svg`  | The "h" block with its offset outline shadow.                             | Large placements only (about 40px and up): hero art, slides, social cards, stickers. |
| `logo-wordmark.svg` | The lowercase `hunk` letters on their own.                                | Next to the mark when the lockup is too heavy; text-only contexts.                   |
| `logo-lockup.svg`   | 3D mark plus wordmark.                                                    | Open Graph images, README banner, talk title slides.                                 |

Other places the mark appears:

| Path                                       | Purpose                                                                 |
| ------------------------------------------ | ----------------------------------------------------------------------- |
| `website/public/favicon.svg`               | Browser tab icon. Flat mark in `#65d291` on a `#111a15` rounded square. |
| `website/public/icon.png`                  | 512px PNG of the favicon (fallback for browsers that skip SVG icons).   |
| `website/public/apple-icon.png`            | 180px apple-touch icon.                                                 |
| `website/src/components/BrandHeader.astro` | Header logo: inline flat mark (24px) plus `hunk` text.                  |
| `website/public/og.png`                    | 1200×630 social card. Not yet on the new logo.                          |
| `website/public/docs/favicon.svg`          | Docs favicon. Not yet updated.                                          |

Regenerate the PNG icons after changing `favicon.svg`:

```bash
rsvg-convert -w 512 -h 512 website/public/favicon.svg -o website/public/icon.png
rsvg-convert -w 180 -h 180 website/public/favicon.svg -o website/public/apple-icon.png
```

### Rules

Clear space and minimum sizes are proposed guidance, not something the site enforces yet.

- **Small means flat.** Below ~40px the 3D outline turns into a smudge; use `logo-mark.svg`.
- **Clear space:** keep at least the width of the stem (about 8% of the mark's width) free on every side. In the lockup, the gap between mark and wordmark is the mark's top-right bar height.
- **Minimum size:** 16px for the flat mark, 40px for the 3D mark, 48px wide for the wordmark.
- **Don't** stretch, rotate, outline, add shadows or gradients, recolor outside the palette below, or retype the wordmark in another font. The wordmark is outlined paths, not live text.
- **Don't** put the mark inside a circle. A rounded square (radius about 19%) is the only container, as in the favicon.

## Color

Light is the default. Dark mode is toggled with `data-theme="dark"`. Tokens live in `website/src/styles/brand.css`.

| Role               | Light     | Dark      | Token                |
| ------------------ | --------- | --------- | -------------------- |
| Background (paper) | `#f4f1ea` | `#0c0e10` | `--hunk-bg`          |
| Surface            | `#fffdf8` | `#14181b` | `--hunk-paper`       |
| Panel              | `#e9e4d8` | `#1b1f24` | `--hunk-panel`       |
| Ink                | `#16140f` | `#f2f4f6` | `--hunk-ink`         |
| Body text          | `#3a352b` | `#c3c9cf` | `--hunk-body`        |
| Muted text         | `#6b6557` | `#9aa4af` | `--hunk-muted`       |
| Rule               | `#dcd6c8` | `#262d34` | `--hunk-rule`        |
| Accent green       | `#1f7a35` | `#5dd06a` | `--hunk-accent`      |
| Accent soft        | `#bfe9c6` | `#2c6b34` | `--hunk-accent-soft` |
| Accent ink         | `#0d3a18` | `#b9f2c0` | `--hunk-accent-ink`  |

- Use the accent green sparingly: the current nav item, the highlighted word in the hero, primary controls.
- The diff colors in screenshots come from the terminal theme, not the brand palette.
- The favicon uses a darker green pair (`#111a15` / `#65d291`) that is not a site token. Align it with the table above or add it as a token if it stays.
- The logo exploration files (C51/C59) were drawn on a saturated blue (`#1F3DFF`). That blue is not part of the site palette; decide before using it anywhere.

## Typography

- **JetBrains Mono** (variable) for everything: headings, body, nav, code, docs. Loaded from `@fontsource-variable/jetbrains-mono` and exposed as `--hunk-font`.
- Fallback stack: `"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`.
- Header logo text is 18px, weight 700, `-0.02em` letter-spacing. Check `marketing.css` and `starlight.css` for the other weights in use.
- Eyebrows (like `TERMINAL DIFFS`) are 13px, uppercase, `0.15em` letter-spacing.
- Use the font via `var(--hunk-font)`; never hard-code another family.

## UI details that make it look like Hunk

- Hard 1px ink borders with a solid offset shadow (`--hunk-shadow`) on cards and the install panel, not blur shadows.
- Minimal chrome: a thin top bar and a single rule under it, no redundant headers.
- Terminal screenshots sit in a window frame with three hollow dots.

## Related branding

- **Modem** ("Built by") uses `website/public/modem-light.svg` in the footer. Keep it at its own aspect ratio and don't merge it with the Hunk mark.
- Product name in prose: Hunk. Wordmark and logo text: `hunk`, lowercase. npm package: `hunkdiff`. Command: `hunk`. Repo: `modem-dev/hunk`.

## Updating this guide

When the palette, font, or logo files change, update the tables above in the same change. The site media rituals (OG image, screenshots) are in `website/MEDIA.md`.

---

### Browser Review Rebuild (`docs/browser-review-rebuild.md`)

# Browser review rebuild plan

> **Current status (2026-09-06; after Phase 4).** Shared review semantics, producer publication, the broker
> mirror, bounded resources, capability-authenticated HTTP actions, and SSE have landed. No browser
> client, served review page, browser assets, or browser CLI entrypoint exists yet. Phases 5 and 6
> remain planned. Semantic deep-link grammar is deferred until its first browser/opener consumer.

The synchronized browser-review feature (originally prototyped in one large branch) lands as a
stack of small, independently reviewable PRs. Its Hunk-owned capability, resource, and semantic
protocol remains separate from the generic per-application daemon contract in
[`session-broker-sdk.md`](session-broker-sdk.md). Each phase has a hard gate and stands on the
previous one. The seam contract — shared primitives stay renderer-free and platform-neutral —
is enforced by `scripts/quality/source-boundaries.test.ts`, whose debt lists may only shrink.

Each phase lists the audit findings it repays (`browser-review-seam-audit.md`, ids A1–G5). A
finding whose duplicate sites span phases is checked off when its **last** site converts; until
then it stays open with the converted sites noted. Phases 0–5 change no user-reachable
behavior (the browser client has no entry point until Phase 6) and ship empty changesets;
Phase 6 carries the `minor` changeset announcing the feature.

## Phase 0 — seam contract and guardrails (this doc)

- Boundary gates for `packages/hunk/src/core/review/` (the shared review model), `packages/hunk/src/session/reviewProtocol.ts`
  (the wire schema), and `packages/hunk/src/web/` (the browser client). The gates tolerate absent trees, so
  they land ahead of the code they constrain.
- A shrink-only debt map for the Node-only primitives the prototype's model files still carry;
  each entry must be repaid with a platform-neutral implementation before a browser bundle may
  import that file.
- The existing architecture boundaries stay at full strength. The prototype relocated bundled
  VCS providers into `packages/hunk/src/core/vcs/` and weakened this suite to compensate; that relocation must
  not ride along with any rebuild phase — extraction PRs land against the restored gates.

## Phase 1 — review model + terminal adoption (three PRs)

1. **Review store**: `state / actions / reducer / store / intents / selectors` in
   `packages/hunk/src/core/review/`, with `useReviewController` / `App` / `AppHost` refactored onto it in the
   same PR. Behavior-neutral; existing PTY integration tests must pass untouched.
2. **Review document projection + diff geometry**: `document / identity / sourceIdentity /
anchors / contentManifest / notes / expansion / reconcile / jsonStream` plus the geometry
   primitives, adopted by the terminal — including switching the terminal's collapsed-gap math
   to `reviewGapAddress` (A1) and note-anchor ranges to full per-side extents (A3). The
   conformance harness (`test/review-conformance/`) and its first adversarial fixtures land
   here; add the directory to the CLAUDE.md test-directory list in the same PR.
3. **Navigation intents + command catalog**: `selection/move` / `selection/select-file` and
   the shared navigation/reveal/note selectors; the agent runtime's `navigateSession` deleted
   in favor of the shared walk; the command catalog split with semantic commands lowered to
   intents. A semantic address grammar landed here and was later removed because it had no
   consumer; recreate it beside the first Phase 5 or 6 consumer.

Repays: A1–A10 (PR 2); B1–B9, B11, F1–F3 (PR 3); D2 core and terminal sites. G3's first grammar was later removed as unused.
B-findings with browser sites stay open until Phase 5 consumes the selectors.
Gate: ladder rungs 1–4 — tombstones appended for every deleted copy, terminal planner
registered in the conformance harness, adversarial fixtures landed per repaid finding, and the
existing PTY suite passing untouched.

## Phase 2 — producer runtime

`packages/hunk/src/app/review/producer.ts` owns generations, snapshot serving, and resource
materialization. `packages/hunk/src/app/session/reviewRuntime.ts` mounts the producer and broker
client for an interactive session. This phase serves the existing `hunk session` surface only.
Resource read failures map to distinct error codes; integrity failures are never collapsed into
`unknown-resource`.

Repays: D1 and D4 producer/snapshot sites (helpers land in core beside the model; remaining
sites convert in Phase 3); D5 producer sites.
Gate: producer registered in the conformance harness; existing `test/session/` suite passing
untouched (rung 4).

## Phase 3 — wire protocol + broker mirror

`reviewProtocol.ts`, broker `wire.ts` validation, broker review mirror, `reviewResourceCache`
(bounded in-flight budget). Patch reconstruction for `hunk session review --include-patch` uses
bounded-parallel loads from day one. Valuable without any web UI: agents get chunked,
digest-verified, memory-bounded resource access. Optional extension-delegated review identity rides
in bounded registration metadata and projects into list, context, and review snapshots; it never
enters `ReviewDocumentV1` or creates a remote reload/provider capability. The wire vocabulary is derived from
`ReviewIntent` (B12) and carries `expandedLineProof` (B10) and actor identity (G2) from its
first version so the browser never needs a schema break.

Repays: B10, B12, G2 wire fields; C1/C2 producer and broker sites (their browser sites close
in Phase 5); D1/D3/D5 broker sites.
Gate: broker suites join the conformance harness (wire round-trip + mirror against the shared
fixtures, including the C1 ordering fixtures); `reviewResources.integration.test.ts` with the
parallel-load test; vocabulary derivation checks active (rung 5).

## Phase 4 — HTTP surface, no client (landed)

`browserReviewServer` + capability auth + SSE, loopback-only, tested with plain `fetch`.
Four routes per live session, mounted inside the existing daemon rather than on a port per
terminal: the current publication (position plus resource catalog), bounded digest-verified
resource reads through the existing mirror and cache, an SSE stream, and action submission
parsed by `reviewProtocol.ts` and forwarded to the existing `apply_review_action` path —
which this gives its first production caller. Range handling covers zero-length resources.
The SSE event contract lives in a shared `reviewEventProtocol` module from day one (C4), and
the user-facing error catalog is created beside the stabilized error codes (G4).

Authorization is a capability the _session_ mints, publishing only its SHA-256 to the
daemon; it rides in the URL fragment and is presented in a request header, so it reaches no
log, path, or query string, and a cross-origin page cannot attach it the way it could a
cookie — which is why there is no CSRF machinery and no CORS header anywhere on the surface.
That replaces the prototype's cookie exchange and, with it, the renewal problem: a
capability lives as long as the session, so a review outlives any TTL by construction. The
cost is that `EventSource` cannot carry a header, so the Phase 5 client reads the stream
with `fetch` — which is also what makes C5's single reconnect scheduler reachable.

Repays: C4 server side; G4 catalog creation.
Gate: HTTP-contract tests against the shared event-protocol module; security review focused on
this PR alone.

## Phase 5 — browser client (two PRs)

1. **Read-only mirror**: `apiClient` / `mirror` / `pierreDocument` / review stream rendering a
   snapshot with Pierre — built on the shared geometry/selector/ordering primitives from day
   one (no `sideRange`, no local acceptance rules, no bare `split("\n")`). No actions, no note
   editing.
2. **Interactivity**: action dispatch through the broker, selection sync (G2 policy decided
   before this PR), note editing, watch/reload generation swaps, browser key bindings and the
   command palette rendered from the shared catalog.

Repays: A11, C3, C5, E1, G1; the browser sites of A/B/C/D findings left open in earlier
phases; F browser bindings; G3/G4 browser adoption. E2 and the G2 selection policy must be
decided (not necessarily built) before PR 2.
Gate: browser projection joins the conformance harness — the same fixtures every other
consumer runs, closing the renderer-parity loop (note placement, gap addressing, reveal
targets, default note targets); web boundary gates and the browser-closure node-free gate
active; command-parity check (both clients render command surfaces from the shared catalog).

## Phase 6 — entry points and packaging

- `--web` / `--no-open` / `hunk session open` / `--tailscale` CLI wiring. The review URL is
  always recoverable from the terminal, and the opener preserves URL fragments on every
  platform (no `rundll32`). Deep-link fragments use the shared address grammar (G3), never
  ad-hoc strings.
- Offline browser assets are generated at build/release time or diff-checked by a script gate,
  not hand-maintained compiled output.

Repays: G3 fragment adoption (closes G3).
Gate: CLI contract tests, offline-asset check, real terminal + browser smoke run, and a final
audit-doc sweep — every A–F finding checked off or explicitly re-scoped, G5 remaining as a
placement rule.

## Seam inventory (prototype audit)

A file-level audit of the prototype found roughly thirty duplicated derivations across the
terminal, browser, agent runtime, and broker — the seam is wider than the review model and wire
schema. Each rebuild phase lands the primitives below and deletes the per-consumer copies it
replaces. This section is the summary; the per-finding work-list with sites and observed
divergences is `browser-review-seam-audit.md`, and extraction PRs check findings off there:

- **Diff geometry** (`core/review`, Phase 1 PR 2): per-side hunk ranges (`reviewHunkRange` —
  the prototype has four implementations, one wrong), gap addressing (`reviewGapAddress` — the
  terminal's own copy is off by one against core's), trailing-gap existence, expansion side,
  hunk rebasing for isolated rendering, normalized source-line splitting (browser skips the
  CRLF normalization core digests depend on), per-file split/unified line totals carried on
  `ReviewFileV1` instead of recomputed, empty-diff reason, default hunk note target, and STML
  tag roles so both renderers share one tag vocabulary.
- **Navigation semantics** (`core/review` intents/selectors, Phase 1 PR 3): relative hunk/file
  navigation with an explicit wrap policy (`selection/move` — the prototype implements the walk
  three times: terminal, agent runtime, and the browser was set to become a third), file-jump
  (`selection/select-file`), selection normalization and fallback, reveal-target resolution,
  active-note selection, notes-by-hunk grouping (owner-index based, not range containment), one
  filter matcher shared by stream and tree, one note-visibility predicate, and one
  viewport-anchor policy. The wire vocabulary is derived from `ReviewIntent` instead of
  hand-restated, and gains the `expandedLineProof` field the browser needs for expanded-line
  selection parity.
- **Ordering and transfer** (`core/review` + protocol, Phases 3–4): one generation/state-
  revision acceptance state machine (the prototype has five with differing rules), one chunk
  assembly/verification helper (four copies, two inside one file), one epoch/supersede queue,
  one reconnect scheduler, and an SSE event-contract module so server and client derive frame
  names, envelopes, and byte bounds from the same constants.
- **Validation single-sourcing** (`reviewProtocol` + `core/review`, Phases 2–4): note byte
  bounds measured one way everywhere (the prototype's per-field vs whole-note split lets an
  oversized note poison the entire snapshot), one empty-body policy, one note-anchor ownership
  calculator (producer, broker, and browser each re-derive it, the broker's copy dropping the
  fallback branch), an order-independent canonical-file/manifest consistency check, shared
  digest validators and exact-key helpers, and no re-declared wire constants.
- **Presentation helpers** (Phase 5): file stat badges and change-kind decorations, plus the
  browser importing shared language registration for side effects.

Deliberately renderer-specific — do not unify: terminal row building, cell measurement, and row
windowing; browser IntersectionObserver windowing and DOM reveal mechanics; STML layout; platform
hashing (`node:crypto` vs Web Crypto); theme palettes, pending a product decision on whether the
browser mirrors the terminal theme.

## Commands and keyboard shortcuts in the browser

The terminal command system (`packages/hunk/src/ui/lib/appCommands.ts`) fuses three separable things per
command: identity (id, title, chords), binding (terminal `KeyEvent` matching), and effect
(closures over live App state). Making commands work in the browser means splitting them, not
transporting them:

- **Catalog as shared data**: id, title, category, default chords, and a declared resolution
  locus move to a renderer-neutral catalog. Menus (`appMenus.ts`), the help dialog, and a
  browser command palette all render from the same catalog, and user `[keybindings]` config
  applies to the catalog rather than to one renderer.
- **Three resolution loci, declared per command**:
  - _Semantic_ — lowers to a `ReviewIntent` and resolves at the producer, with changes
    broadcast to every attached client (hunk/file navigation, annotated navigation, start
    note, toggle gap expansion, toggle agent notes, filter). The browser fires these through
    the existing apply-action wire path — no new wire surface, and the agent runtime's
    `hunk session` commands become a third consumer of the same lowering.
  - _Client-local_ — view state that is deliberately per-client (scrolling, paging, line
    alignment, layout mode, wrap, line numbers, sidebar, menu bar, theme selector, help).
    Each client implements its own handler; identity and chords stay shared so help and
    palettes agree.
  - _Host-only_ — quit, source refresh, edit-in-`$EDITOR`, agent-skill helpers, and all
    extension commands. Not invocable from the browser by default: extension commands execute
    host-side with dialog access, so browser invocation is remote code execution and needs an
    explicit per-command allowlist in the registration capability list. Deferred beyond the
    initial rebuild.
- **Keymap**: chords keep the shared string vocabulary (`keymap.ts`); each client maps chords
  to its own event type and masks what its platform reserves (e.g. browser `Cmd+W`).

Phasing: the catalog split and semantic lowering land in Phase 1 PR 3, alongside the
navigation intents the semantic commands lower to. Browser key bindings and the command
palette land with Phase 5, gated by the shared catalog so the two clients cannot drift on what
a command means. Brokered host-command invocation is explicitly out of scope until the
allowlist design exists.

## Per-phase seam verification

Import gates prove code _may_ use a primitive, not that it _does_ — a consumer can silently
re-derive. Every phase therefore passes the same five-rung ladder, and each rung is mechanical:

1. **Boundary gates** (every phase, exists today): `scripts/quality/source-boundaries.test.ts` —
   import containment, shrink-only debt lists, and the extracted-duplicate tombstone list.
   Repaying an audit finding means deleting the duplicate copies **and appending their paths to
   the tombstone list in the same PR**; a resurrected path fails CI forever after.
2. **Conformance harness** (built in Phase 1 PR 2, grows every phase — not deferred to Phase 5):
   one golden fixture corpus under `test/review-conformance/`, with every consumer registering
   a suite against the _same_ fixtures as it lands — terminal render planning (Phase 1),
   producer projection (Phase 2), broker mirror and wire round-trip (Phase 3), HTTP surface
   (Phase 4), browser projection (Phase 5). A phase's gate is that all previously registered
   suites still pass plus its own joins.
3. **Adversarial fixtures from the audit**: every audit finding that documented a divergence
   contributes the fixture its old copy got wrong — pure-insertion hunks and zero-count sides
   (A1/A2), hunks with leading context (A3), CRLF and no-trailing-newline sources (A4),
   fallback-owner and expanded-gap notes (D3/B8), non-contiguous state revisions and replayed
   events (C1), oversized note bodies at the per-field/whole-note boundary (D1). Expected
   values in these fixtures are written **by hand from the semantics**, not generated by
   calling the primitive — a generated expectation would follow a bug instead of catching it.
   Repaying a finding requires landing its fixture; the finding's checkbox in the audit doc
   points at the fixture file.
4. **Behavior invariance**: adoption phases must not change what users see — Phase 1 passes
   the existing PTY integration suite untouched, Phases 2–3 the existing `test/session/`
   suite. A needed test edit in these phases is a red flag reviewed as a behavior change, not
   a test update.
5. **Vocabulary derivation checks** (Phase 3 on): a test asserts the wire action vocabulary
   equals the intent vocabulary minus a named exclusion list (so a new intent that should be
   wire-reachable cannot be silently forgotten — the B12 failure mode), and that coupled
   constants are imports, not re-declared literals.

Residual risk — a consumer re-implementing a primitive _identically_ passes fixtures until the
copies drift. Where that risk is highest (geometry, ordering rules), spot-check with a seam
probe: temporarily patch the core primitive to return a sentinel in a scratch build and confirm
each consumer's conformance suite fails. Not CI machinery — a release-prep check for Phases 1,
3, and 5.

Process rule tying it together: every extraction PR names the audit finding ids it repays, and
"repaid" is defined mechanically as (a) duplicate copies deleted, (b) tombstones appended,
(c) the finding's adversarial fixture landed, (d) the consumer registered in the conformance
harness. A finding without all four stays open in the audit doc.

---

### Browser Review Seam Audit (`docs/browser-review-seam-audit.md`)

# Browser review seam audit — prototype duplication findings

> **Current status (2026-09-06; after Phase 4).** Core review semantics, terminal adoption, producer and broker
> projections, wire protocol, bounded resources, HTTP actions, and SSE have landed. There is no
> browser client or served review page. Historical prototype locators below may name deleted files;
> each finding's latest status annotation is authoritative.

Companion to `browser-review-rebuild.md`. This is the per-finding work-list behind the plan's
seam inventory: every duplicated derivation found in the prototype, the sites on each side, the
observed or reachable divergence, and the shared primitive that replaces it. File/line
references are against the prototype branch (`feat/browser-review` at merge commit `9bb0ad0`)
and will drift as that branch changes; treat them as locators, not anchors. Each extraction PR
should delete the copies its primitive replaces and check off the finding here.

## Run boundary — after Phase 4

Open work and its planned phase:

- **C3, C5** — the epoch/supersede queue and one reconnect scheduler, both browser-client
  work (Phase 5). C4 is repaid on the server side; its client half closes with the reader
  that imports the same module.
- **The event stream needs a `fetch` reader, not `EventSource`.** The capability is
  presented in a header, which `EventSource` cannot set, so Phase 5's client consumes the
  stream with `fetch` and a streaming reader. That is also what makes C5's "one reconnect
  scheduler" reachable — there is no built-in reconnect to interleave with.
- **No replay buffer.** The surface answers any `Last-Event-ID` with a fresh publication
  rather than retained frames. The stream carries no deltas, so a publication _is_ a
  complete resynchronization and a history would be an optimization; if Phase 5 measures a
  need for one, it is additive.
- **Browser sites of A, B, C, D** — every finding whose latest annotation still requires a
  browser consumer remains open: A3–A11; B3–B8 and B10's client; C1 and C2's client; D1's
  composer, D2 and D5's browser sites, D3's `pierreNoteAnchor`, and D4's
  `parseCanonicalReviewFile`. B5 still needs its browser source policy. B7 and B8 also retain
  terminal adoption/behavior work.
- **E1, E2** — the shared stat-badge formatter, and the product decision about whether the
  browser mirrors the terminal theme. Both Phase 5; E2 must be decided before its second PR.
- **F browser bindings** — F1–F3's browser halves (palette, keymap resolution onto DOM
  events). F4 stays a scope boundary, not work.
- **G1, G2 policy** — view-option classification and persistence, and the multi-client
  selection and authorship policy. G2's _wire fields_ are done (see G2); what a receiver
  should do with an actor tag is the decision, due before Phase 5 PR 2.
- **G3 adoption** — no address grammar exists on current main. It was removed as dead code and
  returns beside the first browser deep-link or opener-fragment consumer (Phases 5–6).
- **G5** — a placement rule for undo, if undo is ever built. Not work.

Remote draft composition now reaches `notes/update-draft` through core and the wire protocol, but
has no browser caller until Phase 5. HTTP publications contain only a position and resource catalog;
resource reads provide canonical files, patches, and sources, while successful actions acknowledge a
new position. Phase 5 must define bounded snapshot or delta transport for live selection, filter,
and note state before a browser can render those changes.

## A. Diff geometry

- **A1. Collapsed-gap line ranges — 3 implementations, terminal off by one.** Terminal
  `packages/hunk/src/ui/diff/pierre.ts` (`leadingCollapsedRanges`/`trailingCollapsedRanges`, ~:456-480) vs
  core `packages/hunk/src/core/review/expansion.ts` (`reviewGapAddress`); web already consumes core's gap
  objects. For a pure-insertion hunk the terminal's leading range is off by one against core's,
  and the trailing math diverges for zero-count sides; a note created on a terminal-expanded
  line can be rejected on reload because `intents.ts` validates against `reviewGapAddress`.
  Fix: terminal calls `reviewGapAddress`; delete its local math.
  _Repaid (Phase 1 PR 2)_: `reviewLeadingGap`/`reviewGapAddress` in `core/review/expansion.ts`;
  `pierre.ts` copies deleted; fixtures `pure-insertion-hunk` and `pure-deletion-hunk` in
  `test/review-conformance/geometryFixtures.ts`; core and terminal render planning both registered.
  _Residual repaid (Pierre 1.3.5)_: the parser now reports the complete leading gap when the
  anchor side has zero rows, so `pure-deletion-hunk` pins lines 1–5 instead of preserving the
  former one-line undercount. A2's separate trailing-gap residual remains.
- **A2. Trailing-context existence — 3 formulations.** `pierre.ts` `trailingCollapsedLines`,
  producer `packages/hunk/src/app/session/registration.ts` (~:131-139, boolean `hasTrailingContext`), core
  `expansion.ts`. The browser can offer a "Trailing context" button whose expansion core then
  rejects (`gap-not-found`). Fix: one `reviewTrailingGap(file)` in core.
  _Repaid (Phase 1 PR 2)_: `reviewTrailingGap` in `core/review/expansion.ts`;
  `trailingCollapsedLines`/`trailingCollapsedRanges` deleted from `pierre.ts`. Residual, recorded
  in that function: a last hunk with a zero-count side leaves the two line-array tails one apart,
  so no trailing gap is offered even though the file has one. Every consumer now agrees on hiding
  it; correcting the count changes terminal output and is staged separately.
- **A3. Per-side hunk range — 4 implementations, one wrong.** `core/liveComments.ts`
  `hunkLineRange`, `core/review/anchors.ts` `reviewHunkRange` (correct), web
  `pierreDocument.ts` `sideRange` (uses changed-line counts `deletionLines`/`additionLines` as
  span lengths — wrong for hunks with leading context, misplacing notes in the browser), and
  `ReviewStream.tsx` re-filtering against manifest ranges. Fix: `reviewHunkRange` everywhere;
  collapse `hunkLineRange` into it.
  _Repaid (Phase 1 PR 2)_: `reviewHunkRange`/`reviewHunkRanges` in `core/review/geometry.ts`;
  `hunkLineRange` deleted and all six terminal/session sites converted; fixture
  `hunk-with-leading-context`.
- **A4. Source-line splitting for expanded context — 3 implementations, browser skips
  normalization.** Terminal `expandCollapsedRows.ts` `sliceLines` and core `anchors.ts`
  `normalizedReviewSourceLines` agree (CRLF-normalize, strip one trailing newline); web
  `ReviewStream.tsx` does a bare `split("\n")` — `\r` glyphs, phantom EOF line, and context
  digests that can mismatch on reload. (`sourceBackedHighlight.ts` `splitSourceLines` is a
  legitimate fourth variant — Pierre's highlighter needs retained newlines; comment it.) Fix:
  export and adopt `normalizedReviewSourceLines`.
  _Repaid (Phase 1 PR 2)_: `normalizedReviewSourceLines` in `core/review/geometry.ts`;
  `expandCollapsedRows.ts` `sliceLines` deleted; `splitSourceLines` carries the comment saying why
  it is legitimately different; fixtures `crlf-source` and `source-without-trailing-newline`.
- **A5. Expansion side policy `deleted ? "old" : "new"` — 3 copies.** Core `intents.ts`
  (authoritative), terminal `diffSectionRowPlan.ts` (recomputed instead of reading `gap.side`),
  web `pierreDocument.ts` fallback ordering. Fix: thread `gap.side`; export
  `reviewExpansionSide(file)`.
  _Repaid (Phase 1 PR 2)_: `reviewExpansionSide` in `core/review/expansion.ts`; both terminal
  recomputations (`diffSectionRowPlan.ts`, `useReviewController.ts`) deleted.
- **A6. Hunk content-index rebasing — 2 copies, opposite `isPartial` conclusions.** Web
  `pierreDocument.ts` `isolatePierreHunk` vs terminal `sourceBackedHighlight.ts` (~:108-199).
  Fix: one `rebaseReviewHunk(hunk, origins)` in core.
  _Repaid (Phase 1 PR 2, terminal site)_: `rebaseReviewHunk` in `core/review/geometry.ts`, adopted
  by `sourceBackedHighlight.ts`. It returns the per-side end indices so a caller can slice or
  validate without re-walking; the browser's isolate-one-hunk use lands on it in Phase 5.
- **A7. File split/unified line totals — web guesses.** `pierreDocument.ts` reconstructs
  `splitLineCount`/`unifiedLineCount` by reducing over hunks; terminal reads Pierre's
  authoritative values. Mis-sizes browser virtualization when the parser counts rows outside
  hunk spans. Fix: carry both on `ReviewFileV1`.
  _Repaid (Phase 1 PR 2, model side)_: `splitLineCount`/`unifiedLineCount` carried on
  `ReviewFileV1` by `core/review/document.ts`; the browser consumes them in Phase 5.
- **A8. Empty-diff explanation — 3 variants with different precedence.** Terminal
  `plannedRowText.ts` `diffMessage` (rename-pure first), web `ReviewStream.tsx` (binary first),
  `staticDiffPager.ts` (extra cases). Same file can explain itself differently per client.
  Fix: `reviewEmptyDiffReason(file)` in core.
  _Repaid (Phase 1 PR 2)_: `reviewEmptyDiffReason` in `core/review/document.ts` with one canonical
  precedence — what the change _is_ outranks how it is stored (`rename-only` -> `binary` ->
  `too-large` -> `new-file` -> `deleted-file` -> `no-hunks`), the review stream's existing order.
  `plannedRowText.ts` and `staticDiffPager.ts` keep their own wording and share the reason; fixture
  `binary-rename-with-no-rows`. The static pager's own order put storage first, so a renamed
  binary or oversized rename now reports as a rename there too.
- **A9. STML tag vocabulary — parse shared, tag semantics forked.** Terminal
  `ui/lib/stml/layout.ts` handles the full vocabulary; web `ReviewNote.tsx` handles a subset
  (everything else flattens to `<span>`) and accepts a `<tag>` alias the terminal lacks. Fix:
  `stmlTagRole(tag)` in `core/review/stml.ts`; both renderers switch on roles.
  _Repaid (Phase 1 PR 2, terminal site)_: `core/review/stml.ts` owns the tag-to-role table;
  `ui/lib/stml/layout.ts` switches on roles and `parse.ts` derives its void/raw-text tags from
  them. The parser itself stays in `ui/lib/stml` for now — it sanitizes through
  `lib/terminalText`, which core may not import — so relocating it is Phase 5 work, before the
  browser can parse notes.
- **A10. Default line target for a hunk note — 2 divergent rules.** Terminal
  `core/liveComments.ts` `firstCommentTargetForHunk` (first added line, else first deleted);
  web `App.tsx` (hunk's first line — usually context). Same action anchors to different lines
  per client. Fix: `reviewDefaultHunkLineTarget(hunk)` in core, used by both (and by reveal).
  _Repaid (Phase 1 PR 2)_: `reviewDefaultHunkLineTarget` in `core/review/geometry.ts`;
  `firstCommentTargetForHunk` deleted and both terminal callers converted; every geometry fixture
  pins the target, `pure-deletion-hunk` and `hunk-with-leading-context` adversarially.
- **A11. Language registration side effect missing in browser.** `core/changeset/fileLanguage.ts`
  registers `.mts`/`.cts`; the web bundle never imports it, so Pierre's own inference runs
  unregistered for files without an explicit `language`. Fix: side-effect import in
  `packages/hunk/src/web/main.tsx` (or fold registration into the shared model).

Renderer-specific, do not unify: terminal row construction and measured-cell windowing
(`rowWindowing.ts`, `diffSectionGeometry.ts`) vs browser IntersectionObserver windowing and
height estimation; move-kind painting (browser lacks the Pierre input — a feature gap, not a
duplication); hunk header text (browser delegates to Pierre separators); platform hashing.

## B. Navigation and selection

- **B1. Hunk navigation across the stream — 3 implementations.** Terminal
  `useReviewController.ts` + `ui/lib/hunks.ts` (cursors, nearest-cursor, multi-step carry);
  agent runtime `reviewSessionRuntime.ts` `navigateSession` (separate walk whose terminal
  parity is asserted only by a comment, and which lacks the terminal's multi-step carry rule);
  browser (none yet — would be the third). Annotated sets are also built from different inputs
  (terminal `DiffFile` model vs `intersectingHunkIndices`). Fix: `selection/move` intent
  (`scope: hunk|file|annotated-hunk|annotated-file`, delta, wrap policy) planned over shared
  selectors; delete `ui/lib/hunks.ts` and the runtime walk.
  _Repaid (Phase 1 PR 3, core and terminal sites)_: `planReviewSelectionMove` in
  `core/review/navigation.ts`, reached through the `selection/move` intent. `ui/lib/hunks.ts` is
  deleted and tombstoned, and the session runtime's separate walk is gone — comment navigation
  (`--next-comment` / `--prev-comment`) now plans the same intent the keyboard does, so the
  multi-step carry rule it lacked applies there too. Fixture `annotated-hunk-multi-step-carry`
  in `test/review-conformance/navigationFixtures.ts`; the planner is registered as a navigation
  consumer. Residual: which hunks count as annotated is still derived from the terminal's merged
  diff-file model (`buildReviewAnnotationIndex`) and handed to the planner as a caller-owned
  fact, because the semantic document does not carry notes yet — one derivation now, consumed by
  both the keyboard and the session.
- **B2. Wrap vs clamp policy split.** `moveToFile` clamps; `moveToAnnotatedFile` wraps
  (`ui/lib/reviewState.ts`). Encode per-scope wrap policy in the `selection/move` intent.
  _Repaid (Phase 1 PR 3)_: `REVIEW_SELECTION_WRAP_POLICY` names the policy per scope —
  hunk/file/annotated-hunk clamp, annotated-file wraps — and `findNextAnnotatedFile` is deleted.
  The asymmetry is today's terminal behavior kept deliberately, including two quirks now stated
  rather than implied: a clamping hunk move at an edge re-selects and re-reveals the same hunk
  while a file move at an edge publishes nothing at all, and annotated-file navigation from a
  file with no notes enters the ring at its start (so the first forward step lands on the ring's
  _second_ entry). Fixture `scope-wrap-and-clamp`.
- **B3. File-jump semantics — hard-coded identically in both clients.** "Hunk 0 + file-top
  reveal" in terminal `App.tsx`/`useReviewController.ts` and web `App.tsx`; the terminal-only
  forward-cross-file alignment rule has no web counterpart. Fix: `selection/select-file`
  intent owning the rule.
  _Repaid (Phase 1 PR 3, core and terminal sites)_: `selection/select-file` owns "first hunk"
  (`REVIEW_FILE_JUMP_HUNK_INDEX`) with `REVIEW_FILE_JUMP_REVEAL` as its default reveal, and the
  forward-cross-file alignment rule moved into the hunk-move planner, where the crossing is
  known. The terminal's `selectFile` lowers to the intent and no longer takes a hunk index.
  Fixture `scope-wrap-and-clamp` pins both reveals. The browser's copy lands in Phase 5.
- **B4. Selection fallback after reload/filter — 2 divergent answers.** Terminal
  `resolveSelectedFile` returns undefined (renders "no file"); web `validSelection` and
  `treeSource.reset` silently fall back to `files[0]`. Core permits `fileKey: null`. Fix:
  `selectNormalizedSelection`/`selectFallbackFileKey` selectors; delete both client fallbacks.
  _Repaid (Phase 1 PR 3, core and terminal sites)_: both selectors live in
  `core/review/selectors.ts`, and `resolveSelectedFile` is deleted. Recorded difference from this
  finding's description: the terminal in this repo did _not_ render "no file" for a selection the
  filter hides — it kept rendering the selected file, and fell back only when the file was gone
  from the document. That behavior is authoritative and is what the selector now states, so
  `fileKey: null` is reached exactly when nothing is visible at all. Fixtures
  `selection-outliving-its-file` and `selection-with-nothing-visible`. The browser's
  `validSelection`/`treeSource.reset` fallbacks close in Phase 5.
- **B5. Filter matching — 3 matchers.** Core `reviewFileMatchesFilter` (path, previousPath,
  agentSummary), terminal `filterReviewFiles` (normalized paths), web tree search
  (canonicalPath only) — browser sidebar and stream can disagree on the same query. Also
  live-per-keystroke (terminal) vs apply-on-Enter (web, which clobbers in-flight typing on
  snapshot). Fix: one matcher; one committed-vs-live decision.
  _Repaid (Phase 1 PR 3, core and terminal sites)_: `reviewFileMatchesFilter` in
  `core/review/selectors.ts` is the only matcher, and `filterReviewFiles` is deleted. The
  terminal's behavior won on both points of difference: paths are normalized before matching
  (core's matcher did not), and the three fields are joined before the substring test, so a query
  may span the boundary between them. Residual: the committed-vs-live decision is still open —
  the terminal matches live per keystroke, and planning reads the immediate filter while
  rendering reads a one-render-deferred copy of it. The browser's tree search closes in Phase 5.
- **B6. Reveal-target derivation — web re-derives, wrongly.** Web `App.tsx` recomputes the
  hunk target line (`newRange ? "new" : "old"`), duplicating core `canonicalLineForHunk` which
  prefers by side counts and requires a backed line — pure-deletion hunks scroll the wrong side
  in the browser. Fix: `selectRevealTarget(state)` selector; clients only resolve DOM/rows.
  _Repaid (Phase 1 PR 3, core site)_: `reviewCanonicalHunkLine` in `core/review/geometry.ts`
  (preferred side first, backed sides only), behind `selectRevealTarget`. Fixture
  `pure-deletion-reveal-target` pins the case the prototype browser got wrong, and pins that a
  hunk's position is its first row while a note about the whole hunk hangs from its first change.
  The terminal's reveal is row geometry it measures itself and stays renderer-local; the browser
  consumes the selector in Phase 5.
- **B7. "Jump to note" target — terminal geometry decides, web ignores.** The
  active-note choice lives in `DiffPane.tsx` row scanning; web never reads
  `reveal.scrollToNote`. Fix: `selectActiveRevealNoteId(state)` in core.
  _Repaid (Phase 1 PR 3, core site)_: `selectActiveRevealNoteId` names the policy — an active
  draft in the selected hunk first, else the note anchored earliest in it, arrival order breaking
  ties — which is what `DiffPane`'s row scan resolves geometrically today. The terminal site
  stays open: it looks the answer up by measured row bounds, and swapping that for the selector
  is a rendering change rather than a semantic one. Browser adoption is Phase 5.
- **B8. Notes-by-hunk grouping — web re-filters by range containment.** `ReviewStream.tsx`
  drops annotations whose anchor came from core's fallback path or expanded context even after
  `pierreDocument` accepted them — notes silently disappear in the browser. Fix: group by
  `ownerHunkIndex` via a shared `selectNotesByHunk`.
  _Repaid (Phase 1 PR 3, core site)_: `selectNotesByHunk` groups by `reviewNoteOwnerHunkIndex`,
  reading the ownership `resolveReviewNoteAnchor` decided instead of re-testing containment. The
  terminal site stays open deliberately: `DiffPane` groups by range overlap and therefore renders
  a note under _every_ hunk it overlaps, so converting it changes what a reviewer sees and
  belongs in a behavior-changing PR, not this one.
- **B9. Note-visibility policy — two core predicates for one rule.**
  `selectors.ts` `reviewNoteVisibleByPolicy` (web path) vs `notes.ts` `alwaysShowReviewNote`
  (terminal path). Collapse to one predicate over `{source}`.
  _Repaid (Phase 1 PR 3)_: one `reviewNoteVisibleByPolicy` over `{source}` in
  `core/review/state.ts`, beside the other stored-note policies; `alwaysShowReviewNote` is
  deleted, and `DiffPane` calls the predicate over the normalized source.
- **B10. Selected-line semantics — browser structurally weaker.** Terminal maps rendered rows
  to semantic side/line with `expandedLineProof` and separates "anchor" from "reveal"; web
  sends raw lines with `reveal` forced on every click, and the wire `selection/set-line` /
  `notes/create-user` actions have no `expandedLineProof` field at all — browser clicks inside
  expanded regions are rejected or mis-sided. Fix: add the proof to the wire schema; make
  reveal an explicit caller decision in both clients.
  _Repaid (Phase 3, wire and producer sites)_: `ReviewExpandedLineClaim` in
  `core/review/expansion.ts` states what a caller claims — the gap, the side, the line, and the
  identity of the source it expanded — and `resolveReviewExpandedLine` is the one thing that
  decides whether the claim holds. The wire carries it as `expandedLineProof` on the two
  actions that can name a line (`notes/start-draft`'s target, and `notes/create-user`'s
  precondition on the draft it is saving), refusing evidence that accompanies no line, and
  `packages/hunk/src/app/session/reviewCommands.ts` checks it before planning. Where the resulting note hangs
  is deliberately _not_ decided there: it goes through `reviewLineAnchor`'s fallback owner
  exactly as a terminal note does, which `reviewCommands.test.ts` pins by asserting an empty
  intersection set and the declared owner. Fixtures `start-draft-on-an-expanded-line`,
  `create-user-note-at-an-expanded-line`, and `start-draft-with-a-proof-about-nothing` in
  `test/review-conformance/wireFixtures.ts`. Reveal is already an explicit caller decision on
  every intent that carries one; the browser half of both closes in Phase 5.
- **B11. Viewport-driven selection — opposite policies.** Terminal publishes
  nearest-hunk-to-center into shared state; web keeps IntersectionObserver results local. With
  both clients attached, terminal scrolling rewrites shared selection under the browser. Fix:
  one core policy (e.g. `selection/anchor` intent that never bumps reveal tokens).
  _Repaid (Phase 1 PR 3, core and terminal sites)_: `REVIEW_VIEWPORT_ANCHOR_REVEAL` in
  `core/review/state.ts`, and the `selection/anchor` intent that carries it. The terminal's
  viewport-centered hunk selection and its line-cursor anchoring both publish through it, and the
  local `preserveViewport` selection option is deleted. The policy is stated, but the
  multi-client question it exists for — whether an anchor should be shared at all — is G2,
  decided before Phase 5 PR 2.
- **B12. Wire vocabulary hand-restates `ReviewIntent` — 3 places.** `HunkReviewActionV1`
  union, capability list in `registration.ts`, validation list in `wire.ts`, remapped
  field-by-field in `reviewSessionRuntime.ts`; forgotten fields/actions are silently
  unreachable from the browser. Fix: derive the wire type and both lists from one
  `HUNK_REVIEW_ACTION_TYPES` source; validate-and-narrow rather than restate.
  _Repaid (Phase 3)_: `REVIEW_INTENT_TYPES` in `core/review/intents.ts` is the vocabulary,
  made total in both directions by type assertions — a member added to `ReviewIntent` and not
  listed fails to typecheck, and a listed name that is not an intent fails too.
  `HUNK_REVIEW_ACTION_TYPES` _is_ that list, and nothing is withheld: a semantic intent
  resolves at the producer and is broadcast to every attached surface, so every one of them
  belongs to every surface. Withholding one would mean subtracting it by name, with the reason
  it is not shareable. The wire _type_ is derived
  the same way — `HunkReviewActionV1` is `ReviewIntent` with the two wire-only fields added to
  the members that need them — and `toReviewIntent` strips them again, so an action is
  validated and narrowed rather than restated. The action-type-to-parser table is keyed by the
  vocabulary, so a wire-reachable intent without a parser does not compile.
  `scripts/quality/review-vocabulary.test.ts` is the ladder's rung 5: it asserts the equality, that
  every exclusion names a real intent once, and that every type in the vocabulary really
  reaches a parser. Round-trip fixtures for every action live in
  `test/review-conformance/wireFixtures.ts`, registered as the `review wire protocol` consumer.
  _Repaid (note editing and replies)_: `notes/update-draft` now carries remote composer text,
  while `notes/start-edit`, `notes/start-reply`, and `notes/update-user` share saved-note
  mutation and reply semantics across terminal and remote surfaces. Their strict parsers and
  hand-authored wire fixtures keep the expanded vocabulary total.

Renderer-specific, do not unify: terminal line-cursor measurement/reconciliation; browser
requestAnimationFrame/MutationObserver reveal mechanics; tree presentation details (duplicate
path suffixes, expansion retention, git-status badges).

## C. Transport and lifecycle

- **C1. Generation/state-revision acceptance — 5 implementations, 3 policies.** Web
  `mirror.ts` (retired-generation memory; requires contiguous `+1` revisions — a contract the
  server does not guarantee, since initial snapshots and `Last-Event-ID` replay can skip),
  broker `state.ts` (accepts equal revisions for width-only refreshes), producer
  `reviewSessionRuntime.ts` (equality required except selection actions), `wire.ts` parse-time
  triple-equality, plus a fifth copy in web `App.tsx` compensating for the first being
  stricter than the broker. Fix: `core/review/generationOrder.ts` exporting the invariant and
  `classifySnapshot`/`classifyState` → `accepted|stale|gap`, consumed by all five sites.
  _Repaid (Phase 2, producer site)_: `core/review/generationOrder.ts` states the invariant
  once — a generation carries a producer id and a sequence that advances by exactly one, and
  revisions strictly increase but need not be contiguous — and `classifyReviewPublication`
  answers `accepted | stale | gap`. `assertReviewPublicationAdvance` is the producer's own
  side of it, checked before any generation is swapped in. Two decisions worth stating:
  revision skips are legal (the prototype client's contiguous `+1` rule is the bug, not the
  contract), and a republication carrying no new semantic position — a renderer width changed
  and nothing about the review did — classifies as a replay, so the broker's "accept equal
  revisions for width-only refreshes" case becomes a publication-key decision at the producer
  rather than a looser comparison. Fixtures live in
  `test/review-conformance/orderingFixtures.ts` and cover both the classification and the
  transitions a real producer emits. Broker and browser sites close in Phases 3 and 5.
  _Repaid (Phase 3, broker site)_: the daemon's `ReviewMirror`
  (`packages/hunk/src/session/broker/reviewMirror.ts`) holds one publication per session and orders every
  arriving one with a single `classifyReviewPublication` call — `accepted` advances the
  revision, `gap` replaces the generation and retires everything derived from the old one,
  `stale` is ignored. It has no comparison of its own, so the prototype's "accept equal
  revisions for width-only refreshes" case does not exist here: a republication carrying no
  new position is a replay, exactly as the contract says. The one non-ordering rule it does
  apply is stated as such — a later generation is adoptable only together with the catalog
  describing it, because a mirror holding a position whose resources it cannot name would
  advertise reads nobody can serve. `packages/hunk/src/app/session/reviewCommands.ts` makes the same one
  call for an action's `expectedStateRevision`, so "has the review moved past what this
  caller decided from" is the same question as "is this publication ahead". The mirror is
  registered against the Phase 2 fixtures as the `broker review mirror` ordering consumer,
  which is what proves it has no rules of its own. The browser site closes in Phase 5.
- **C2. Chunk assembly + verification — 4 copies, 2 in one file.** Web `apiClient.ts` range
  loop; broker `state.ts` materializing and pre-sized loops (which already disagree on
  progress/eof rules); SSE reassembly in `mirror.ts`. Three in-flight dedupe key formats;
  digest case normalized on a different operand in each. Fix:
  `core/review/resourceAssembly.ts` (`ChunkAssembler` with expected size/digest, bounded
  progress, verified finalize) plus one keyed single-flight helper; HTTP Range/abort, broker
  reservations, and concurrency tuning stay at the edges.
  _Repaid (Phase 2, producer site)_: `core/review/resources.ts` owns resource addressing, the
  chunk bound both ends validate against, and the failure vocabulary;
  `packages/hunk/src/app/review/resourceStore.ts` produces and serves the bytes. Single flight is
  structural rather than a cache bolted on — a read reaches the underlying reader only
  through the in-flight map — and bulk loads run under an explicit concurrency limit instead
  of an unbounded `Promise.all`, which is the pair of defects the original review found.
  Digest comparison normalizes both operands (`reviewDigestsEqual`). The reader half — chunk
  reassembly against an expected size and digest — lands with the broker in Phase 3 and
  consumes the same module.
  _Repaid (Phase 3, broker site)_: `core/review/resourceAssembly.ts` is the reader half —
  one `ReviewChunkAssembler` that holds a stream to the size and digest it declares, requires
  each chunk to start where the last ended, treats a chunk that neither advances nor ends as
  a failure, accepts a zero-length resource as one empty end-of-stream chunk, and verifies
  the assembled bytes against the digest with a `ReviewDigestFn` the caller injects. The
  broker's load loop (`packages/hunk/src/session/broker/state.ts`) does nothing but ask for the next window
  and decode it; there is one loop, where the prototype had two near-verbatim copies inside
  this file that already disagreed about progress and end-of-stream. Bounding is
  `reviewResourceCache.ts`: an LRU with a daemon-wide byte budget plus a reservation taken
  before any bytes are requested, so assemblies in flight are bounded too — and an unmeasured
  resource reserves one chunk and is resized to what the writer declares rather than
  reserving its kind's ceiling, which is what let a handful of ordinary patches serialize the
  parallel loads. Single flight is one map keyed by session, generation, and resource id;
  concurrent callers await the same assembly. `packages/hunk/src/session/broker/reviewResources.integration.test.ts`
  drives the whole path with only the socket replaced. The browser's `apiClient` range loop
  closes in Phase 5.
- **C3. Epoch/supersede/trailing-retry — 2 parallel machines.** Runtime reload queue
  (`reloadEpochSequence`/`supersededReloads`) vs web snapshot recovery
  (`recoveryEpoch`/trailing while-loop), plus three unrelated anti-spin timing constants. Fix:
  one epoch-queue primitive; keep DOM/React wiring local.
- **C4. SSE event contract defined on both ends.** Frame names (`${type}-begin/-chunk/-end`),
  begin/end envelopes, and the event-id grammar are built in `browserReviewServer.ts` and
  re-declared/regex-parsed in `mirror.ts`/`apiClient.ts`; client bounds (12 MiB / 1024 chunks)
  are unlinked from server bounds and only coincidentally compatible. Fix:
  `packages/hunk/src/session/reviewEventProtocol.ts` owning names, envelopes, id grammar, and bounds derived
  from `MAX_BROWSER_REVIEW_SNAPSHOT_BYTES`.
  _Repaid (Phase 4, server side)_: `packages/hunk/src/session/reviewEventProtocol.ts` owns the event
  vocabulary, the frame names and their phases, the begin/chunk/end envelopes and their
  parsers, the event-id grammar, and every bound — `MAX_REVIEW_EVENT_PAYLOAD_BYTES` is the
  protocol's envelope bound, `REVIEW_EVENT_CHUNK_BYTES` is the shared resource chunk size,
  and `MAX_REVIEW_EVENT_CHUNKS` is the quotient, so a sender asking for smaller windows is
  clamped to the ceiling a reader is allowed to hold rather than emitting frames the reader
  will refuse. `browserReviewServer.ts` imports all of it and declares none of it; the
  browser client imports the same module unchanged in Phase 5, which
  `scripts/quality/source-boundaries.test.ts` keeps possible by gating the module's transitive
  closure platform-free. Two decisions differ from the prototype deliberately: a chunked
  payload is framed and verified as the byte stream it is, so reading it is the shared
  `ReviewChunkAssembler` rather than a fourth reassembly loop (C2's rule applied here); and
  only the frame that _completes_ an event carries an `id`, so a `Last-Event-ID` can never
  name a position inside a half-delivered payload. Fixtures
  `publication-exactly-one-window` and `publication-one-byte-over-a-window` in
  `test/review-conformance/eventFixtures.ts` pin the boundary the two ends must agree on,
  and both the protocol and the real HTTP surface are registered as event consumers. The
  browser reader joins in Phase 5, which is when this finding closes.
- **C5. Reconnect/backoff — 4 schedulers, 1 verbatim duplicate.** `apiClient.ts` (exp/4 s),
  web `App.tsx` (exp/4 s + anti-spin), `brokerClient.ts` (fixed 3 s — re-implementing the
  scheduler of the connection it already configures), `session-broker/connection.ts`. Fix: one
  `createReconnectScheduler` in `@hunk/session-broker-core`; EventSource's built-in reconnect
  interplay stays client-side.

## D. Notes and validation

- **D1. Note byte bounds measured in two units.** Wire checks `body` and `markup` separately
  against `MAX_REVIEW_NOTE_BYTES`; broker/producer check whole-note JSON — so a note that
  passes action validation can poison the entire snapshot with a capacity error. Neither
  client pre-checks size, and the server's action-body cap is smaller than the largest
  "valid" note. Fix: one `reviewNoteWithinSizeLimit` used by wire, broker, producer, and both
  composers.
  _Repaid (Phase 2, core and producer sites)_: `core/review/noteSize.ts` measures the whole
  note in the unit a transport pays — its serialized bytes, through the platform-free
  `utf8ByteLength` — and `MAX_REVIEW_NOTE_BYTES` sits beside it. Fixtures
  `test/review-conformance/noteSize.ts` pin the boundary the two prototype rules disagreed
  at, including a note whose summary, rationale, and markup each fit while the note itself is
  three times the bound. Wire and composer sites adopt it in Phases 3 and 5.
  _Repaid (Phase 3, wire site)_: `isTransportableReviewNote` in `packages/hunk/src/session/reviewProtocol.ts`
  is `reviewNoteWithinSizeLimit` and nothing else — the wire has no per-field check any more, and
  declares no second bound. The protocol module is registered as a consumer of the note-size
  corpus, so `every-field-fits-but-the-note-does-not` — the note whose summary, rationale, and
  markup each pass a per-field check while the note is triple the bound — is now refused at the
  wire rather than admitted and then failing at the publisher. Both composer sites are Phase 5.
- **D2. Empty-body policy — 5 declarations.** Core intents (throws), terminal (cancels
  draft — a deliberate UX difference worth keeping explicit), web in three places. Fix: one
  `isBlankReviewNoteBody` predicate.
  _Repaid (Phase 1 PR 2, core and terminal sites)_: the predicate lives in
  `core/review/intents.ts`, and `test/review-conformance/noteBodies.ts` pins the boundary cases
  against both the predicate and the draft-persistence plan they drive.
- **D3. Note-anchor ownership — 4 implementations.** Core `resolveReviewNoteAnchor`
  (authoritative, with fallback-owner support), `notes.ts` sibling over Pierre geometry,
  broker `wire.ts` re-derivation that omits the fallback branch (a legal expanded-gap note can
  get a whole registration rejected), web `pierreNoteAnchor` without the count clamp. Fix: one
  owner/intersection calculator parameterized over a range accessor.
  _Repaid (Phase 3, broker and wire sites)_: by deletion rather than by extraction — the
  daemon has no anchor calculator at all. The broker mirrors a position and a resource
  catalog, forwards an action, and never sees a note's ranges; ownership is decided once, by
  `resolveReviewNoteAnchor` at the producer, reached through the intent. That is what makes
  the prototype's failure impossible rather than merely fixed: its broker copy re-derived
  intersections, omitted the fallback branch, and rejected a legal expanded-gap note — and
  with it the whole registration. The case is pinned from the wire end in
  `packages/hunk/src/app/session/reviewCommands.test.ts`: a note created remotely on an expanded-gap line
  ends up with an empty intersection set and the fallback owner the caller declared, which is
  exactly the shape the dropped branch produced. Web `pierreNoteAnchor` closes in Phase 5.
- **D4. Canonical-file ↔ manifest consistency — 3 checks, 3 field lists.** Producer
  `registration.ts` (authoritative ~17 fields), web `parseCanonicalReviewFile` (12, and
  compares `flags`/`sourceResourceIds` via key-order-sensitive `JSON.stringify` — lazily
  inserted source ids can spuriously error a file), broker legacy check (10). Nobody compares
  canonical hunk _content_ to manifest hunks. Fix: one order-independent
  `assertCanonicalFileMatchesManifest` in core; producer self-check reuses it.
  (`contentManifest.ts` is legitimately different — a parity-test snapshot, not a validator.)
  _Repaid (Phase 2, core and producer sites)_: `core/review/canonicalFile.ts` carries no field
  list of its own — it projects the candidate file into a content manifest entry and compares
  that by value at every level, so the list cannot drift from what the model says a file is,
  key order never participates, and hunk content is checked alongside the geometry derived
  from it. The manifest gained the content that requires (patch text, hunk blocks, source
  identity) and stays a snapshot rather than becoming the validator. The producer self-checks
  with it before serving any canonical file, and the conformance harness runs that check over
  every fixture. The browser and broker copies go when those tiers land.
- **D5. Validator/constant hygiene.** `isReviewSha256Digest` exists but is bypassed by five
  inline regexes with case-sensitivity drift; raw `createHash("sha256")` at seven sites
  instead of `reviewDigest`; `hasExactKeys` private while the pattern is inlined ~10×;
  action-envelope parsing duplicated between `reviewProtocol.ts` and `browserReviewServer.ts`;
  wire constants re-declared as literals (client range size vs server response cap, snapshot
  bounds, filter length cap) and re-typed in tests. Fix: export the validators/helpers from
  `reviewProtocol`/`brokerWireParsers`; import constants everywhere, and derive coupled bounds
  from each other. Also name the intentionally stricter `resolution === "active"` filter
  (`isActiveStoredReviewNote`) beside `isRenderableStoredReviewNote`, and comment why
  `parseReviewState` notes intentionally skip manifest-geometry matching.
  _Repaid (Phase 2, core and producer sites)_: `core/review/validation.ts` owns the digest
  vocabulary and the exact-key check the producer validates with and the wire protocol will.
  `isReviewSha256Digest` accepts only the canonical lowercase form — the case-insensitive
  variant is what let a writer and a reader disagree — with `normalizeReviewDigest` for values
  arriving from outside and `reviewDigestsEqual` normalizing _both_ operands. Hashing itself is
  an injected `ReviewDigestFn` rather than inline `createHash` calls; the producer supplies
  Node's at the edge (`packages/hunk/src/core/reviewDigest.ts`), which is also what repaid the shared model's
  last node-debt entry. Resource bounds are constants in `core/review/resources.ts` that the
  producer imports rather than restates. Wire constants, the action-envelope parser, and the
  two note-filter namings are Phase 3.
  _Repaid (Phase 3, wire and broker sites)_: the resource-read request moved into
  `core/review/resources.ts`, so the producer and the wire parse one shape with one parser
  instead of the producer owning it and the wire copying it; `REVIEW_RESOURCE_LOAD_CONCURRENCY`
  moved beside the other resource bounds for the same reason, now that both the producer and
  the daemon run bulk loads. The failure vocabulary is composed rather than restated
  (`HunkReviewFailureCodeV1` = resource + request + intent-planning codes), the navigable
  scopes are read out of `REVIEW_SELECTION_WRAP_POLICY` rather than listed again, the
  catalog's file bound is derived from its own resource count instead of duplicating the
  registration's file limit, and every digest check is `isReviewSha256Digest`. The one
  coupling the protocol cannot express as an import is the transport frame size — importing
  the broker package would cost the module its browser safety — so
  `scripts/quality/review-vocabulary.test.ts` asserts it instead, alongside a check that no session
  module re-declares a name the review model exports and that no module writes its own
  64-character digest pattern. Naming `isActiveStoredReviewNote` beside
  `isRenderableStoredReviewNote` and commenting `parseReviewState` are browser-tier work and
  stay open with the rest of D5's browser sites.

## E. Presentation helpers

- **E1. File stat badges.** Terminal `ui/lib/files.ts` `formatSidebarStat` (zero-hiding,
  truncation marker) vs web inline `+${additions} −${deletions}` in `treeSource.ts`. One
  shared formatter.
- **E2. Theme.** Web hardcodes two standalone palettes disconnected from `packages/hunk/src/ui/themes` and
  the `AppTheme` mapping; whether the browser mirrors the terminal theme is an open product
  decision — decide before Phase 5, don't unify by default.

## F. Commands and keybindings (preemptive — the prototype browser had none)

The prototype browser client shipped no command system, so these are not observed duplications
like A–E; they are the copies the browser _would_ grow the moment shortcuts are added, recorded
here so the extraction happens before the duplication exists. Design detail in
`browser-review-rebuild.md` § "Commands and keyboard shortcuts in the browser".

- **F1. Command catalog fused with terminal binding and effects.** `packages/hunk/src/ui/lib/appCommands.ts`
  couples identity (id, title, chords), binding (terminal `KeyEvent` matchers), and effect
  (closures over live App state) in one table; menus (`ui/lib/appMenus.ts`) and the help
  dialog render from it, so a browser palette or help screen would have to restate the list.
  Fix: extract a renderer-neutral catalog (id, title, category, default chords, resolution
  locus — semantic / client-local / host-only); terminal keeps matchers and handlers, browser
  adds its own, both render menus/help/palette from the catalog.
  _Repaid (Phase 1 PR 3)_: `packages/hunk/src/core/run/commandCatalog.ts` carries id, title, category, default
  chords, resolution locus, extension visibility, and menu-closing behavior for all 44 built-ins.
  `ui/lib/appCommands.ts` builds its dispatch table from it — the handler map is keyed by
  `AppCommandId`, so a catalogued command with no terminal handler fails to typecheck — and
  menus and help keep reading identity through that table. Parity is asserted in
  `appCommands.test.ts` ("command catalog parity"): the table is exactly the catalog in catalog
  order, and no menu item or help row names a command the catalog does not declare. Placement
  note: the catalog sits outside `core/review`, which stays review semantics only, so Phase 5
  must add it to the web boundary gate's allowed import targets.
- **F2. Semantic command effects are closures instead of intent dispatches.** The ~15
  review-semantic commands (hunk/file/annotated navigation, start note, toggle gap, toggle
  agent notes, filter) run as App closures; a browser implementation would re-derive each
  behavior — the exact drift class of B1/B3/B6. Fix: lower semantic commands to
  `ReviewIntent`s (the Phase 1 store refactor is the same work); the browser fires them
  through the existing apply-action path, and the agent runtime's `hunk session` surface
  becomes a third consumer of the same lowering.
  _Repaid (Phase 1 PR 3, core and terminal sites)_: semantic entries declare their effect as data
  (`AppCommandReviewEffect`), and `lowerAppCommandToReviewIntent` is the one constructor turning
  a command plus a repeat count into a `ReviewIntent`. The terminal's navigation handlers read
  the scope and direction from that same declaration rather than restating them. Two semantic
  commands have no intent to lower to yet — starting a note needs caller-owned draft identity,
  and gap expansion is the Phase 2 `expansion/toggle` intent — and are listed by name in
  `SEMANTIC_COMMANDS_WITHOUT_REVIEW_EFFECT`, so the gap is a decision rather than an oversight.
  _Closed (Phase 2)_: both landed. `notes/start-draft` takes caller-owned draft identity and
  an optional client-measured line, falling back to the shared whole-hunk default;
  `expansion/toggle` resolves through `reviewGapAddress` and reports the side, ranges, and
  source identity a caller needs to fill the gap. Which gap the command reaches is
  `selectReviewGapForSelection` in core, replacing the terminal's `selectGapForKeyboardToggle`.
  `SEMANTIC_COMMANDS_WITHOUT_REVIEW_EFFECT` is now empty, and every semantic command lowers to
  an intent a remote client could fire.
  _Repaid (terminal adoption)_: `useTerminalReview.ts` calls
  `lowerAppCommandToReviewIntent`, so the terminal no longer rebuilds semantic command intents
  inline. The future browser palette remains a second surface, not a prerequisite for terminal
  parity.
- **F3. Keymap resolution is terminal-owned.** Chords are shared config strings (`keymap.ts`,
  `[keybindings]`), but resolution against defaults and conflict handling lives with the
  terminal table; a browser keymap would duplicate it and drift on user rebinds. Fix: resolve
  user keybindings against the catalog once; each client maps resolved chords to its own event
  type and masks platform-reserved chords (browser `Cmd+W` etc.).
  _Repaid (Phase 1 PR 3, host site)_: `builtinCommandKeyDefaults` reads the catalog, so
  `[keybindings]` resolution, conflict detection against extension commands, and key labels all
  fold user config over catalogued defaults exactly once. Mapping resolved chords onto a client's
  own event type stays per client, which is the part that cannot be shared; the browser's half
  lands in Phase 5.
- **F4. Host-only and extension commands — do not expose, by design.** Quit, source refresh,
  edit-in-`$EDITOR`, agent-skill helpers, and all extension commands execute host-side (with
  dialog access); browser invocation is remote code execution into the terminal session.
  Excluded from the browser until an explicit per-command allowlist exists in the registration
  capability list. This is a scope boundary, not a missing feature.

## G. Other preemptive seams (same class as F)

Further capabilities where the terminal (or multi-client operation itself) has semantics the
prototype browser never grew; recorded so the shared primitive exists before a second
implementation does.

- **G1. View defaults and option classification.** Terminal view options resolve through the
  layered defaults chain (built-ins → user config → repo `.hunk/config.toml` → command
  sections → CLI flags); the prototype browser hardcodes its own defaults (dark theme, layout)
  and has no persistence at all. Two decisions to make once: (a) which options are shared
  review state vs per-client view state — `showAgentNotes` and filter are already shared,
  layout/wrap/theme are per-client — recorded as an explicit classification on the option
  schema, not implied by where code happens to read them; (b) whether the browser receives the
  host's resolved view defaults as its starting point (it should — the host already computed
  them) with per-client overrides persisted client-side. Phase 5.
- **G2. Actor identity and multi-client selection policy.** Notes carry an optional `author`,
  but wire actions carry no actor/client identity — with a terminal, a browser, and agents
  attached to one session, nothing distinguishes who moved the selection or wrote a note, and
  B11 (viewport-driven selection) already showed the two clients fighting over shared
  selection. Decide the model once: actions carry a client/actor tag; selection is either
  shared-with-follow-mode or per-client-with-optional-follow (product decision); note
  authorship defaults from the actor. Wire fields in Phase 3, policy before Phase 5 PR 2.
  _Repaid (Phase 3, wire fields)_: `HunkReviewActorV1` — an opaque `clientId`, a coarse
  `kind` (`terminal` / `browser` / `agent`), and an optional display name — is required on
  every action and resource-read envelope from the protocol's first version. The finding's
  four parts split cleanly, and only the first is done: (1) actions carry an actor tag, here;
  (2) whether selection is shared-with-follow or per-client-with-follow is a product decision,
  due before Phase 5 PR 2; (3) note authorship defaulting from the actor lands with remote
  note composition in Phase 5; (4) how a client obtains its identity was originally assigned to
  Phase 4. The wire parser validates the tag, the broker forwards it to the owning session, and
  the producer-side action handler drops it before semantic planning. A client cannot widen what
  it may do by claiming a kind; adding policy later changes behavior rather than the schema.
  _Amended (Phase 4)_: part (4) does not land with the HTTP surface after all, and the
  reason is worth stating. The capability authorizes _a review_, not _a client_: one link
  may be opened in several tabs, and the surface deliberately cannot tell them apart,
  because a credential that identified a tab would be a credential a tab could be tracked
  by. The actor therefore arrives in the action envelope, opaque and non-authoritative, and
  minting a per-client identity belongs with the client that needs one — Phase 5, beside the
  selection policy that is the only thing which will read it. Parts (2) and (3) are
  unchanged.
- **G3. Semantic addressing / permalinks.** The prototype's URL fragment carries only the
  capability token — there is no grammar for addressing a file/hunk/line/note. Three consumers
  will need one: browser deep links and back/forward history, a terminal "copy link" command,
  and agent surfaces already addressing targets by file/hunk. One serialize/parse address
  grammar over semantic keys (`fileKey`/`hunkIndex`/side/line/noteId — never array indices or
  rendered rows) in `core/review`, used everywhere an address crosses a boundary. Core
  primitive in Phase 1; browser adoption Phase 5; opener fragments Phase 6.
  _Repaid (Phase 1 PR 3, core primitive)_: `core/review/address.ts` serializes and parses the
  four address kinds over percent-encoded semantic identifiers, with round-trip coverage for keys
  carrying separators, percent signs, and non-ASCII characters, and strict rejection of anything
  outside the grammar. No consumers yet, by design — browser deep links are Phase 5 and opener
  fragments Phase 6, which is when this finding closes.
  _Reopened (2026-08-17, module-boundaries phase 0)_: `core/review/address.ts` is deleted. A
  primitive with no consumers cannot be checked against what its consumers need, and the
  `no-dead-modules` rule now flags exactly that shape. The grammar above still stands as the
  design; write it again beside the first consumer that addresses a review across a boundary.
- **G4. User-facing error catalog.** The repo already solves this once for agents:
  `packages/hunk/src/session/agent/errors.ts` single-sources every message the generated skill quotes, with
  contract tests. The browser has no equivalent — action rejections (`invalid-action`,
  `stale-generation`, resource integrity failures) would surface as ad-hoc strings invented in
  `packages/hunk/src/web`, drifting from what the terminal shows for the same failure. One error-code →
  user-message catalog beside the wire protocol, consumed by both clients (and reused by the
  agent surface where codes overlap). Phase 4 (codes stabilize) / Phase 5 (browser consumes).
  _Repaid (Phase 4, catalog creation)_: `packages/hunk/src/session/reviewErrorCatalog.ts` gives every code a
  statement and a remedy, in the agent surface's own pattern. Totality is mechanical rather
  than reviewed: the catalog is a `Record` over `HunkReviewClientErrorCodeV1`, itself
  _composed_ — resource plus request plus intent-planning plus the transport's own codes —
  so a code added to any of the four tiers fails to typecheck until it has a message, and
  `reviewErrorCatalog.test.ts` states the vocabulary by hand rather than reading it back out
  of the thing under test. Messages carry no interpolated caller input, so they can be
  rendered anywhere, including where echoing a request back would be wrong; the HTTP surface
  uses a catalog message unless the producer supplied a more specific one. The status map
  lives with the transport (`browserReviewServer.ts`) rather than in the catalog, because a
  client reads codes and an HTTP status is not something to tell a person. The agent surface
  keeps its own wording — its codes are `hunk session` CLI failures rather than these, and
  the two vocabularies do not yet overlap. Browser adoption is Phase 5, which is when this
  finding closes.
- **G5. Undo, if it ever arrives.** Note editing today has no undo. If it is added, the
  history/undo semantics belong in the shared reducer (which client undoes what, across
  actors), never in one client's keyboard handler. Recorded as a placement rule, not work.

## Verification hooks

The per-phase verification ladder lives in `browser-review-rebuild.md` § "Per-phase seam
verification". A finding here counts as repaid only when all four hold: duplicate copies
deleted, their paths (for whole files) or banned-symbol entries (for function-level
deletions) appended to the tombstone lists in `scripts/quality/source-boundaries.test.ts`,
the finding's adversarial fixture landed in the conformance harness, and the consumer
registered against that harness.

- The seam boundary tests (`scripts/quality/source-boundaries.test.ts`) keep deleted copies deleted.
- Renderer parity tests (Phase 5 gate) drive shared fixtures through the terminal planner and
  browser projection and assert identical note placement, gap addressing, reveal targets, and
  default note targets — the drift class import gates cannot catch.
- `contentManifest.ts` should additionally cover derived geometry (A1–A4) so parity snapshots
  fail when a renderer re-derives instead of consuming core.
- Command parity (F1–F3): help/menu/palette listings in both clients render from the shared
  catalog in tests, so a command added to one client without catalog registration fails rather
  than forking the vocabulary.

---

### Changelog On Hunk Dev (`docs/changelog-on-hunk-dev.md`)

# Release notes on hunk.dev

`CHANGELOG.md` is the source of truth for what shipped. `scripts/generate/generate-changelog.ts` projects it
into the pages published at `hunk.dev/changelog`, the same way `scripts/generate/generate-docs.ts` projects
runtime metadata into the CLI and config references. Nothing under the generated paths is
hand-edited; `bun run check:changelog` fails the build when they drift.

## Why the site carries release notes

Hunk publishes 40-plus releases at roughly two a week, and before this that content existed only in
`CHANGELOG.md` and GitHub Releases — about a quarter as much prose as the whole docs site, none of it
on hunk.dev. Three things follow from hosting it here:

- **Release cadence becomes a freshness signal** for a site that is otherwise mostly static docs.
- **Answer engines can finally answer version questions.** The site already ships `llms.txt`,
  `llms-full.txt`, a `.md` twin of every page, and an explicit crawler allow-list; release history
  was the largest gap in that corpus. Changelog pages join `llms-full.txt` automatically and are
  excluded from `llms-small.txt`, which stays scoped to learning Hunk.
- **Release videos get a permanent home.** `skills/hunk-launch-video` renders the real TUI for every
  release; a GitHub attachment URL is unindexable and unembeddable.

## One page per minor series

`/changelog/0.18/` covers 0.18.0 and every patch after it, rather than one page per version. Half of
all releases are single-bullet patches, and a URL per version would produce mostly thin pages that
rank for nothing. Grouping yields around twenty substantial pages and matches how the question is
asked — what is in 0.18, not what is in 0.18.2.

Dated prereleases publish on the same series page as stable releases. A series that has only reached
beta gets its page, index row, feed item, and social card as soon as the prerelease tag supplies a
date, while an undated prerelease preparation remains invisible. Prereleases never advance the
stable `Latest` marker, landing-page ribbon, or default install command.

```text
/changelog/                 index over every series
/changelog/0.18/            series page
/changelog/0.18/#v0-18-2    stable per-release anchor
/changelog/0.21/#v0-21-0-beta-0 prerelease anchor
/changelog/rss.xml          feed, one stable item per series plus one item per prerelease
/changelog/0.18.md          Markdown twin, via starlight-dot-md
```

Astro slugifies filenames and would publish `0.18.md` at `/changelog/018/`, so every series page
pins its URL with an explicit `slug` in frontmatter. Astro also derives `#0182` from a version
heading, so each release emits a readable `v0-18-2` anchor beside its heading.

## Inputs

| Path                           | Owner         | Contents                                                                                                |
| ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------- |
| `CHANGELOG.md`                 | Changesets    | Every release, in both the Changesets format and the pre-0.16 Keep a Changelog format. Both are parsed. |
| `website/releases/notes.json`  | Hand-authored | Per-series `summary`, `tagline`, `links`, and `video`. Every field optional.                            |
| `website/releases/dates.json`  | Generator     | Version to release date. Committed.                                                                     |
| `website/releases/latest.json` | Generator     | Newest published stable release, imported by the landing-page ribbon.                                   |
| `website/releases/cards.json`  | Generator     | Content of each social card, read by the card renderer.                                                 |

The `### Highlights` block in `CHANGELOG.md` is already hand-written for minor releases, so the page
uses it directly: its lead paragraph becomes the page summary and its bullets become the Highlights
section. The overlay only adds what the changelog has no place for.

## Dates and the pre-tag window

The Changesets format carries no dates, and the release tag does not exist when
`bun run release:version` runs — the version-preparation branch is generated, reviewed, and merged
before the tag is pushed. So the site can build from a commit describing a version that nobody can
install yet.

Two rules handle this without a draft state or an extra publication step:

- **A recorded date is never recomputed.** Dates live in `website/releases/dates.json`; only a
  version missing from it reads the annotated tag's publication date, falling back to the tagged
  commit date for a lightweight tag; either lookup fails soft. Generation is therefore deterministic,
  and `check:changelog` gates CI on Vercel's shallow, tagless
  checkout without touching Git.
- **Publication state is derived from dates, not from position.** An undated stable version renders
  as `Unreleased`; an undated prerelease remains off every surface until its tag exists. Neither can
  advance the index's `Latest` marker, enter the feed, or contribute an install command. Stable notes
  are accurate when their preparation branch merges, while beta notes appear only once installable.

`website/releases/dates.json` is rebuilt from the release list rather than merged onto it, so a
version that leaves `CHANGELOG.md` does not linger in the committed map. Stable and prerelease tag
dates are both retained.

Step 4 of `skills/hunk-release/SKILL.md` regenerates after every tag, including backports. Every tag
backfills its date and publishes its notes; only the newest stable tag adds the default install command and advances the
landing-page ribbon. RSS keeps one mutable item per stable series and gives every prerelease an
exact-version anchor and GUID, so the later stable release still reaches subscribers as a new item.

## Surfaces

The generator emits Markdown only, so the pages stay clean for the `.md` twin and the agent corpus.
Presentation lives in the site:

- `BrandHeader.astro` carries the nav item; `DocsHeader.astro` derives the active item from the
  route, because Starlight renders the changelog and the docs through the same shell.
- `DocsMarkdownContent.astro` marks changelog articles with `data-changelog`, which is what lets
  `starlight.css` style the generated structure — an h3 per version, a meta line, h4 change sections
  — as a release list without per-page markup. Starlight renders headings inline inside a wrapper,
  so the rule between releases sits on the generated `.release-separator` anchor rather than on the
  heading, where a border would span only its text.
- Generated pages set `editUrl: false`; an edit link would invite hand-edits that regeneration
  silently overwrites.
- The landing page imports `website/releases/latest.json` for its release ribbon, so the current
  version is a static import rather than a build-time parse of generated Markdown.

`vercel.json` must keep `CHANGELOG.md` and `scripts/generate/generate-changelog.ts` in its `ignoreCommand`
path list, or release commits will not trigger a deploy.

## Social cards

Every changelog page carries its own OpenGraph image instead of the site-wide `og.png`, because a
release announcement is the most-shared page the site has.

`bun run generate:changelog` derives what belongs on each card into `website/releases/cards.json`,
and each page's frontmatter `head` points at `/changelog/og/<slug>.png`. A beta-only card labels its
metadata as `Prerelease` instead of receiving the stable `Latest` pill. Starlight merges page head
entries over the global ones by tag and property, so a changelog page replaces the site-wide image
while ordinary docs pages keep it.

`bun run generate:og` paints the cards with Chromium at 1200x630 and commits them. Rendering needs a
browser, so it is a maintainer step rather than part of the website build: the changelog generator
records which cards should exist and reports missing images — failing under `--check` — while the
renderer is what draws them. Point `CHROMIUM` at a browser binary to use one other than Playwright's
bundled build.

The layout is the site's own paper surface. Two content rules shape it: a series with no editorial
summary drops the tagline rather than padding it with the factual fallback (about half of them have
none), and patch chips appear only when a series has more than one release.

## Not built yet

- **Release videos.** The `video` field is implemented end to end, including `VideoObject` schema,
  but no release has a hosted video URL. Host the encoded output rather than committing it; the
  launch-video pipeline keeps generated media out of Git.
- **Contributor lists.** The GitHub release bodies name first-time contributors, which is community
  goodwill and organic links. `CHANGELOG.md` does not carry authors, so this needs a second input.
- **The in-app update notice.** `packages/hunk/src/core/process/updateNotice.ts` tells users a new version exists without
  linking what changed. Appending `hunk.dev/changelog/<minor>` is the highest-intent entry point
  available and is tracked separately.

---

### Extension Api Evaluation (`docs/extension-api-evaluation.md`)

# Extension API field notes: Review triage

> **Historical field report (July 2026).** This evaluation describes the API when the review-triage
> example was introduced. Use the [extension guide](extensions.md) and
> [extension architecture](extension-architecture.md) for current behavior. Since this report,
> command handlers gained review navigation and pane components gained exact host-owned width and
> height. Host-managed persistence, richer dialogs/menu layout, and a public pane viewport-scroll
> contract remain open design areas.

`examples/extensions/review-triage/` is a deliberately ordinary, user-installable extension built only against `hunkdiff/extension`. It provides a session-local hunk triage board: a reviewer can open a right sidebar, navigate through public hunk summaries, mark the current hunk approved/investigate/blocked with an optional rationale, and clear decisions. Its commands are ordinary **Extensions** menu entries, while lifecycle and bus events keep the board current.

Building it validated the API's central path: a third-party extension can compose a React sidebar, menu-reachable commands, host-owned modal dialogs, selection snapshots, lifecycle subscriptions, notifications, and a small inter-extension bus without imports from Hunk internals. The PTY integration test loads this exact directory rather than a string fixture.

## Findings

### Sidebar geometry and selection following were missing

**Current status:** partially resolved. Pane props now expose exact host-owned width and height;
viewport bounds and a host scroll-item capability are still not public.

At the time of this report, the public sidebar props exposed width but not pane height, viewport bounds, scroll position, or a way to scroll an item into view. The bundled file sidebar uses host-internal `ScrollBoxRenderable` viewport events and `scrollChildIntoView`; a third-party sidebar cannot reproduce its windowing or follow-selection behavior. Review triage therefore uses a simple scrollbox and compact rows, but selected hunks can fall out of view for a large review.

**Suggested addition:** expose read-only pane viewport geometry plus a narrow `actions.scrollItemIntoView(id)` capability (or a supported scrollbox ref contract). This would let extensions virtualize and retain selection visibility without exposing OpenTUI internals.

### Extensions have no safe, host-managed persistence

The triage board can only be session-local. Extension config is repository-overridable and expressly untrusted for exec-adjacent decisions; using it as writable storage would be wrong. Writing an arbitrary file from the extension is possible but creates incompatible location, lifecycle, privacy, and cleanup policies for every author. Reloading can also change a file id or hunk index, so blindly persisting the current key would misapply decisions.

**Suggested addition:** a namespaced, user-owned storage API with explicit scopes such as session and local-user/repository, plus a changeset identity available for reconciliation. Hunk should own the file location and trust semantics.

### Command handlers could not navigate the review stream

**Current status:** resolved. `ExtensionCommandContext.navigation` now exposes guarded
`selectFile`, `selectHunk`, and `revealLine` actions.

At the time of this report, commands received a selection snapshot, dialogs, and sidebar open/close controls, but no `selectFile` or `selectHunk`. A "next blocked hunk" command therefore cannot navigate directly; it would need to rely on a mounted sidebar to perform navigation, which is both indirect and unreliable on a narrow terminal. Review triage avoids shipping that misleading command and makes hunk rows clickable instead.

**Implemented:** command context now exposes the guarded navigation methods used by pane actions.

### Dialogs are intentionally simple, but triage exposes their limits

The select/input sequence works well for a short status and single-line rationale. There is no structured option value (only displayed strings), validation hook, multiline input, or way to retain a dialog target if the session reloads; reload cancellation is safe and correct, but an extension has to design around it.

**Suggested addition:** retain the current simple primitives, then consider labelled `{ value, label }` select choices and a multiline input primitive. Dialog requests should still cancel on reload rather than acting on stale review state.

### The Extensions menu is command-generated, not extensible layout

Commands make the extension visible in the Extensions menu and are sufficient for this workflow. But they cannot add a custom submenu, separator, checked state, disabled state, or an entry elsewhere in the menu bar. This is an appropriate initial boundary, but it means command titles must carry more UI work than a purpose-built menu model.

**Suggested addition:** no change is required yet. If richer menu integration is added, model it as declarative command state rather than arbitrary extension renderables in chrome.

## Non-gaps confirmed by the extension

- A React component loaded from disk can render in Hunk's tree and use hooks when it imports the host-served `react` module.
- The public hunk summaries and sidebar selection/index contract are sufficient to render and drive a hunk-level board without accessing opaque diff metadata.
- `useSyncExternalStore` is a viable bridge from detached lifecycle callbacks to sidebar rendering, including while the sidebar is closed.
- Host-rendered dialogs provide appropriate attribution and modal behavior; command registrations provide menu and keyboard access through one mechanism.
- Lifecycle events and the namespaced bus are sufficient for session-local, fire-and-forget coordination, as long as the extension treats them as observers rather than persistence or request/response channels.

---

### Extension Architecture (`docs/extension-architecture.md`)

# Extension system architecture

Maintainer-facing map of how the extension system hangs together. The
authoring guide for extension users is [docs/extensions.md](extensions.md);
this doc is about Hunk's own internals. Each module named here carries a
header comment with the full local story — read those for depth; this page
exists so you know which module owns what.

## Tiers and loading

Extensions come in user-installed and bundled tiers, all running through the same per-extension
API object and registry collection (`packages/hunk/src/extensions/runExtension.ts`):

- **User extensions** load during app bootstrap after initial config resolution and before final
  session bootstrap (`packages/hunk/src/app/extensionBootstrap.ts`,
  `packages/hunk/src/extensions/startup.ts`, `packages/hunk/src/extensions/host.ts`). Discovery
  groups and trust gating live in `packages/hunk/src/extensions/discovery.ts` and
  `packages/hunk/src/extensions/trust.ts`.
- **Bundled extensions** are compiled into the binary. The private
  `packages/hunk-{git,jj,sapling}` provider workspaces are statically imported by
  `packages/hunk/src/extensions/default/vcs/index.ts`; the app composition root
  (`app/vcsCatalog.ts`) loads them synchronously before config resolution, so backends exist
  without making core import the extension host. `default/ui/index.ts` is deliberately not part of
  that list: the UI loads its bundled files pane, delegated review-info panes, and content search
  registrations through `runExtensionFactory`, once per process. The private `packages/hunk-gh`
  workspace registers `hunk gh` through `default/core/index.ts` into each session-owned extension
  registry, so a delegated temporary patch follows the same shutdown lifetime as that session.

Git, built-in file navigation, and change-request or history-commit identity use the public
`registerVcsAdapter` and `registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens)
extension exercises current-line pane paint through that same public contract.

Bundled extensions are implicitly trusted and stay loaded under
`--no-extensions`, which governs user discovery and loading only. Independently selectable
capabilities also carry a host-owned **selection id**, separate from their registration namespace;
for example, `hunk.gh` selects the bundled GitHub factory while its public command registrations
remain under `hunk`. `packages/hunk/src/core/run/extensionSelection.ts` resolves user and repo deny-lists
plus ordered CLI overrides without I/O. `startup.ts` applies that decision to `hunk.gh` and user
candidates before factory execution, module import, or trust prompting, and includes the selection
snapshot in staged-load compatibility.

An extension id is a file stem the user chose, and it is the namespace that id
owns for commands (`<extensionId>.<commandId>`), panes
(`<extensionId>:<viewId>`), and config (`[extension.<id>]`). `host.ts` is the
one place those ids are vetted — discovery stays a pure filesystem walk, and
every way an id can be derived arrives there as `candidate.id`. It refuses
reserved ids (`hunk`, plus the base catalog's bundled backend ids), ids outside
`/^[A-Za-z0-9][A-Za-z0-9_-]*$/` (a dot or colon would make the composed ids
unsplittable), and the later of two sources claiming one id; each refusal is a
load issue and costs only that extension. The rules themselves are stated in
`packages/hunk/src/extensions/extensionIds.ts`.

## Managed Git and npm installation

`extensions/manage/source.ts` parses Git/local-checkout and npm specifications;
`install.ts` owns staging, validation, promotion/rollback, and management operations
for both backends. Existing untagged install records normalize as Git records.
Git acquisition retains clone/ref and dependency behavior. `manage/npm.ts` invokes
npm with scripts and automatic peer installation disabled, leaving registry,
authentication, integrity checks, and dependency resolution to the package manager.
npm locates the launch project's configuration with `npm prefix`; a temporary staging
`.npmrc` preserves project/user/environment precedence while install runs from the
launch directory to preserve relative configuration paths. The copy is removed before
validation or promotion, including failed acquisitions, so project credentials are not
persisted in the workspace.
Local-mode and safety policy apply through both CLI flags and case/separator-normalized npm
environment configuration so child npm processes cannot inherit conflicting settings.
A randomized, non-existent script-shell path fails closed if npm attempts a hook
anyway; this covers Git `prepare` behavior in npm versions that ignore suppression.

Each npm install owns an isolated workspace containing its package and dependencies
in their normal `node_modules` layout. A generated workspace-root `hunk` manifest
points to the installed package's validated entries and retains its API requirement,
so `discovery.ts` needs no registry-specific semantics and never scans dependencies.
The runtime source hook uses Bun's compiler to preserve CommonJS exports as ESM when
helpers require host modules; compiler-generated host require calls become static
virtual imports so their lazy factories settle before helper evaluation. Dependencies
stay external rather than being bundled into a parallel module graph.
`manage/workspace.ts` recognizes package entries owned by npm records, including
aliased paths; `hostRuntimeModules.ts` scopes host-import rewriting to their complete
isolated module workspace so hoisted helpers can consume host peers. Neighboring
installs, unrecorded folders, and Git source scopes are not widened.
The single-entry ID derives from a bounded package-name prefix and full SHA-256;
multiple entries follow existing manifest identity/order rules. CLI package aliases
resolve to that storage name; updates persist the requested selector and resolved
version separately. npm acquisition and validation failures clean staging and leave
the previous directory and record untouched.

## One registry model, separate composition paths

Registrations (session behavior, themes, file languages, VCS adapters,
changeset transforms, panes, interactive commands, top-level CLI commands,
lifecycle/UI events, and inter-extension bus listeners) collect into an
`ExtensionRegistry` (`packages/hunk/src/extensions/types.ts`). Bundled VCS, bundled UI, and user
extensions use separate registry instances and lifecycle owners. `app/vcsCatalog.ts` composes
bundled VCS registrations directly; `ui/lib/extensionPanes.ts` reads bundled UI pane registrations
and `ui/lib/sessionRegistrations.ts` composes bundled UI commands and line highlighters ahead of
the user registry's (so the bundled `/` search stays active under `--no-extensions`); and
`extensions/apply.ts` applies user registrations during session bootstrap and reload.
File-language registrations stay as
declarative extension, filename, or glob selectors until `fileLanguageLookup.ts` resolves them;
Hunk then pins that answer into Pierre's metadata so rendering cannot re-derive a conflicting
language. A live reload replaces the compiled selector generation while preparing its changeset
and restores the previous generation if any pre-commit step fails. Staged external-VCS bootstrap
retains the provisional candidate/config snapshot: a final pass that
only appends repo candidates extends the same registry, while a changed prefix
receives bounded `shutdown` before being rebuilt. Live registry replacement uses
the same shutdown/startup lifecycle. `src/extensions/session.ts` owns active, provisional, and
retiring registries by identity; it synchronously closes authority at adoption/shutdown and drains
all known bounded retirements. Surfaces borrow that session and cannot retire it independently.
A user or bundled-VCS factory that throws is rolled back to its pre-run registration counts
(`runExtension.ts`); failures cost a warning, not the session. Bundled UI registration instead
requires every pane its factories declare and throws if Hunk's own invariant fails.

Generic CLI commands deliberately remain separate from the interactive named-command
table. `parseCli` resolves known built-ins first, preserving static help/version and
headless fast paths. Only an unknown top-level token produces an `extension-cli`
envelope and enters the extension bootstrap. Enabled bundled core registrations load first even
when user extensions are disabled; optional user discovery follows, and the first registration owns
the raw subtree and runs through leased process I/O. An exit result retires the
registry before returning an exit plan; a one-time built-in delegation reparses
through the ordinary planner. Delegated reviews reconcile the already loaded
candidate/config prefix and hand the same registry to `AppBootstrap`, so factories
are not rerun merely for the handoff. A delegated patch may attach a validated, bounded
provider-neutral review descriptor. Startup carries it beside the input on `AppBootstrap`; it does
not enter the changeset transform pipeline or `ReviewDocumentV1`. The host preserves it only while
the same file-backed patch identity reloads and clears it when a reload selects another input.
Session registration projects the same optional bounded descriptor into list, selected-context, and
review exports through strict app-wire validation. It remains registration metadata rather than a
`ReviewDocumentV1` field and does not imply any provider or remote-reload capability. Headless
delegation retires before executing the built-in plan. Terminal probing occurs only after
the handler releases I/O.

## Host-served runtime modules

Extension files import `react`, `@opentui/*`, and `hunkdiff/extension` as
host-served runtime modules (`packages/hunk/src/extensions/hostRuntimeModules.ts`): a
per-extension-directory Bun loader hook transpiles extension source and
rewrites those specifiers to prefixed virtual modules backed by the host's
own instances. That identity is what lets `registerPane` components
render inside the app's React tree with working hooks. The module header
documents why the obvious alternatives don't work (process-wide specifier
claims break the host's lazy imports; the loaders resolve lazily so headless
commands never pay OpenTUI's native-library extraction).

The rewrite reads transpiled syntax and lexical scopes, so documentation strings,
member calls, and locally bound `require` functions stay unchanged. Other bare
package references resolve through `installedPackageResolution.ts` against the
importer's real filesystem directory, including nested/subpath exports and
Bun/Node import-versus-require conditions. Reachable ESM dependency files receive
file-scoped hooks too, including relative helpers outside the entry directory;
unrelated files and native CommonJS packages are not claimed by those hooks.
Resolution failures do not fall back to the compiled loader, which can bypass
blocked exports. Failed dynamic imports and runtime `require` calls target lazy
failure modules so the extension can catch the error when the operation executes.
The asynchronous source hook still cannot serve synchronous `require()` of
JavaScript/CommonJS source; this change does not introduce a second evaluator
to work around it.

## Four-edge pane system

`packages/hunk/src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle
plan for panes, dividers, and review bounds. Left/right panes consume columns;
top/bottom panes consume rows from the central review column, outside review
stream coordinates. Pane registrations may opt into a body-axis `fraction`;
the planner resolves it to an integer target before applying bounds and lets a
session-local divider drag override that automatic size.

`packages/hunk/src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions,
immutable review metadata, and failure containment. The fixed three-row `hunk:review-info` top
pane uses one border row above two metadata rows and is available for delegated change requests or
commits selected from interactive history, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row
painter plus the public `{ side, line }` address — without publishing Pierre
rows, plans, cursor keys, or caches. Deprecated sidebar APIs
normalize into this same registry and layout path.

## File-view system

File-view registrations are selected per file but remain inside the one
host-owned review stream. `packages/hunk/src/ui/fileViews/useFileViews.ts` bounds asynchronous
extension work and retains only immutable layouts accepted by
`packages/hunk/src/ui/fileViews/layout.ts`; width and registration identity are part of that
accepted geometry. A stateful view has no such identity to change, so
`ctx.fileViews.refresh` bumps an invalidation epoch owned by
`packages/hunk/src/ui/fileViews/useFilePresentationController.ts` and modeled in
`packages/hunk/src/ui/fileViews/state.ts`. That epoch participates in the same retention key,
re-preparing the files presenting that view while their current rows stay
visible. One map counts both view-wide and per-file invalidation, and
`fileViewLayoutEpoch` is the single place that composes them into the epoch a
`(file, view)` preparation is retained under. `packages/hunk/src/ui/fileViews/renderPlan.ts` is the shared insertion
plan for validated extension rows and host-owned inline notes. It resolves only
unambiguous exact-source bindings and returns an explicit unresolved set, so
`DiffPane` falls the complete file back to Pierre rather than guessing or
silently dropping review data. `packages/hunk/src/ui/fileViews/geometry.ts` measures that same
plan, and `packages/hunk/src/ui/components/panes/FileView.tsx` windows and paints it. Extension
components can paint only their fixed validated rectangles; note cards,
scrolling, hunk bounds, and navigation remain host-owned.

## Line-highlight system

Line highlighters mark character ranges inside Hunk's own diff rendering, so
the system is deliberately split between a pull-based preparation half and a
paint-only application half. `packages/hunk/src/ui/highlights/useLineHighlights.ts` bounds
asynchronous extension work with the same timeout/concurrency discipline as
file views and retains only marks accepted by
`packages/hunk/src/ui/highlights/validate.ts`; results cache under `(file, highlighter,
epoch)`, and each file's merged mark array keeps a stable identity while its
inputs are unchanged so row memoization can hold. The epoch is owned by
`packages/hunk/src/ui/highlights/useLineHighlightsController.ts` behind
`ctx.highlights.refresh`, using the shared scoped-epoch policy in
`packages/hunk/src/ui/lib/scopedEpochs.ts` — the same module `packages/hunk/src/ui/fileViews/state.ts`
delegates to — and the shared bounded `readDocument` capability lives in
`packages/hunk/src/ui/lib/extensionDocumentReader.ts`.

Application is paint-time by construction. `packages/hunk/src/ui/diff/lineHighlightPaint.ts`
owns the one mapping from source coordinates (raw code-unit offsets) to
terminal columns — sanitize-aware, tab-aware, snapped outward to grapheme
clusters, with context and gap lines sharing one range list under both side
keys — and the one span transform that repaints backgrounds without changing
text. `packages/hunk/src/ui/diff/rowStyle.ts` resolves tones against the actual line
background with the word-diff minimum-contrast guarantee.
`packages/hunk/src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter,
which keeps highlights out of `buildDiffSectionRowPlan`, its caches, and every
geometry measurement: a highlight change is a repaint, never a re-plan.
`packages/hunk/src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row
adaptation there supports the public OpenTUI and extension current-line surfaces, while
`packages/hunk/src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never
runs extension code, so highlights are interactive-only.

Agent attention marks (`hunk session highlight add` / `clear`) join this same
pipeline rather than growing a second one: `useTerminalReview.ts` validates
each daemon-pushed mark with the same `validate.ts` contract and caps, holds
them per file, and `packages/hunk/src/ui/highlights/merge.ts` appends them after extension
marks in the one map `DiffPane` paints from — so agent marks share paint,
contrast, and geometry guarantees, and win where ranges overlap. Unlike
extension marks, nothing re-derives agent marks after a reload, so
`packages/hunk/src/ui/highlights/reconcile.ts` carries them across a document replacement only
for files whose `contentIdentity` is unchanged — those still show the same
characters — and drops the rest. Line-target `session navigate` reuses the same
`revealLine` landing policy `ctx.navigation.revealLine` gets.

`packages/hunk/src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback
containment. The presentation controller stores the active mode and funnels all
exit paths through one teardown, including re-entrant handoffs.

Keyboard routing checks file-view modes after focused inputs and before session
keyboard modes and app commands. `"handled"` and `"exit"` consume the key;
`"pass"` continues normal routing. Escape remains host-owned.

Session-wide modes registered through `registerKeyboardMode` are resolved with
the same extension ownership and first-registration rules as other surfaces.
`packages/hunk/src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session
mode, with eager ref state for input chunks, registry-generation authority,
contained synchronous lifecycle callbacks, and one teardown used by Escape,
status, menu, reload, and unmount. Mode controls are activation-scoped;
`onEnter` and `onExit` cannot change ownership, while `onKey` may deliberately
replace its activation without letting the outgoing callback defeat recovery or
manipulate the replacement.
`packages/hunk/src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot
used by both session and file-view mode delivery, so OpenTUI events and their
consumption methods never cross the extension boundary. Their shared
`packages/hunk/src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures,
rejects thenables without leaving unhandled rejections, and normalizes key
results; each mode module supplies only its context and attributed warnings. A
focused file-view mode may overlap and temporarily outrank a session mode;
leaving it resumes the session mode rather than destroying unrelated state.

## Command system

Every app-level keyboard shortcut is a named command in one dispatch table
(`packages/hunk/src/ui/lib/appCommands.ts`), each id under Hunk's reserved vendor namespace
(`hunk.app.quit`, `hunk.review.nextHunk`) — which is what keeps built-in ids
and extension-owned ids in disjoint spaces however either grows; modal surfaces
(dialogs, menus, focused inputs) own their keys first and are deliberately not
commands. Extension
`registerCommand` entries join the same table via
`packages/hunk/src/ui/lib/extensionCommands.ts` — built-ins win key conflicts, refused one
chord at a time and detected by probing matchers with a synthesized event
(`packages/hunk/src/lib/commandKeys.ts`). Command handlers receive pane controls and a selection snapshot from
`packages/hunk/src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the
panes render plus a copied source address for the active current-line cursor.
App reads it through a ref so the dispatch table stays stable while line
navigation moves. `ctx.review.snapshot()` takes the complementary whole-review
path: `packages/hunk/src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's
document identities and complete saved-note collections, preserving core-owned
anchors and reconciliation verdicts. App pairs that state with the producer's
current generation under the same review capability lease, so retained controls
return `null` after reload instead of reading replacement content. The extension
projection is registered in `test/review-conformance/` as a real semantic
consumer rather than rebuilding note placement in the command host.

`packages/hunk/src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both
`ctx.navigation` and a pane's `actions`, so a jump from either surface is
validated, attributed, and reported the same way. It owns argument policy only
— visible-file validation, hunk clamping, `revealLine`'s side and line-number
checks — and delegates the move itself to the terminal review adapter. Where a
jump puts a line on screen stays host policy: `useTerminalReview` tags each
current-line reveal with a placement, and `DiffPane` reads it to choose between
stepping's minimum-distance scroll and the top-padded position hunk, note, and
`revealLine` reveals share. Extensions name a target; they never name a scroll
position.

After any named command dispatches in the terminal host, App emits
`command_executed` with its stable id. This observes the accepted action after
synchronous `AppCommand.run`, not settlement of detached extension promises.
The event decorates the assembled terminal command table, so
keyboard dispatch, menus, and extension command controls share one observation
path. Browser/session review actions lower through `ReviewIntent` instead; they
are semantic effects rather than terminal command invocations. Widget-owned
modal keys also remain outside the table and therefore outside the event.

`ctx.dialogs` is the one place extension code can interrupt the user, so its
ordering and settlement live outside React in
`packages/hunk/src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a
per-extension `dialogs` object, normalizing (and sanitizing) extension-authored
text into a request the host draws, and answering by request id so a duplicated
Enter cannot spill onto whatever was queued behind. App subscribes with
`useSyncExternalStore`, renders the current request through
`packages/hunk/src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`;
select and input are `ModalFrame` surfaces), and unmount calls `shutdown()` so
every pending and queued dialog resolves its cancel value instead of leaving a
handler awaiting forever. Key precedence in `useAppKeyboardShortcuts` places
dialogs below Hunk's own app-critical prompts (repo trust, save-on-quit) and
above menus, help, the theme selector, focused inputs, file-view modes, session
keyboard modes, and the command table: an extension may
interrupt review navigation, never a decision about the session itself. The
frame carries an `ext <id>` attribution row — the toast marker — for every
user-installed extension, because its title is extension-authored and a prompt
must not be able to impersonate Hunk. The host derives the extension's trusted
bundled origin from registry metadata and omits the redundant marker only for
Hunk-owned bundled UI. `packages/hunk/src/ui/lib/modalGeometry.ts` clamps the frame before
extension text is wrapped or windowed, so measurement and rendering use the
same terminal width; body/options yield rows to a pinned mouse-clickable action
footer on short terminals.

The bottom status row is the same kind of host-owned surface with the same
lifetimes. `packages/hunk/src/ui/statusLine/` holds a renderer-free store
(`store.ts`: insertion-ordered items plus a FIFO prompt queue that settles like
the dialog queue), a deterministic width/priority layout (`layout.ts`), one
`StatusLine` component that owns the focused input and the badge's
click-to-exit, and `extensionControls.ts`, which mints the per-extension
`statusLine` and `prompts` objects, namespaces item ids under the extension, and
validates extension-authored items and options. `App` and `LogApp` each mount
one store: the file filter and `hunk log` search are its first consumers, so the
host and extensions share one prompt path and one overflow policy. A prompt is a
host focused input and routes with the filter, ahead of file-view and session
modes; items survive content reloads and clear with the registry.

Lifecycle and bus handlers receive that same attributed dialog queue plus the
same guarded live navigation commands use. They can also request a current-input soft reload;
the current-review controller registers the latest reloadable descriptor, then AppHost resolves
that descriptor at queue execution and coalesces extension requests while serializing them with
manual, watch, workspace, and daemon reloads. `App` installs these controls through the
per-extension event-context provider, while `AppHost` keeps the content/broker/React commit gate
and asks the owning `ExtensionSession` to adopt only after those facts agree. `AppHost` publishes mounted
lifecycle order (`startup`, then `changeset_loaded`; reloads add
`session_reload`) only after the matching child commit. Headless or pre-mount delivery resolves
dialogs to their cancel values and refuses navigation with a warning.
`packages/hunk/src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation,
dialog, review-reload, and workspace controls to one App, extension registry, and review
generation. Soft
reload or registry retirement therefore makes old host-mediated capabilities
inert before shutdown begins. Session behavior requests are registry data too:
`resolveExtensionSessionOptions` applies the shared policy that any
`configureSession({ viewPreferences: "transient" })` request makes practice and
presentation view changes ephemeral without teaching `App` about an extension
id.

`packages/hunk/src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads
resolve reviewed file ids through the existing source fetcher, which retains
ownership of caching and size limits. Missing or unreadable sources become
`null`.

Writes are limited to reloadable working-tree reviews and reviewed paths inside
the review root. App supplies the current input, unfiltered changeset, and root
through refs so soft reloads update the policy inputs. The host verifies the
filesystem target before and after consent, writes it, then calls
`refreshCurrentInput`. Consent uses the existing extension-dialog queue.

Commands declare chords, not matchers: `packages/hunk/src/ui/lib/keymap.ts` folds every
command's `defaultKeys` against the user's `[keybindings]` table (user config
layer only) into one id-to-chords answer, from which matchers, key labels, and
conflict probes are all derived — a user-bound chord is exclusive, so whatever
held it by default gives it up. The chord grammar itself lives in
`packages/hunk/src/extension-api/keys.ts` because it is published as `hunkdiff/extension`
(`matchesKey`, `parseKeyChord`, `matchesKeyChord`) for extension components
that need internal keys; `packages/hunk/src/lib/commandKeys.ts` re-exports it inward and
keeps the host-only pieces.

The table is also the only description of what each action is called and which
key runs it, so the mouse surfaces read from it rather than restating it: the
dropdown menus (`packages/hunk/src/ui/lib/appMenus.ts`) declare items as command ids plus
menu-specific wording and checkbox state, and the controls help dialog
(`packages/hunk/src/ui/lib/helpContent.ts`) declares curated rows the same way — both render
their key text from resolved `keyLabels` and run entries through
`executeAppCommand`. A few commands ship with `defaultKeys: []` because they
exist for a menu item; they never match a key but remain bindable by id.

Command handlers receive guarded `ctx.commands` controls built by
`packages/hunk/src/ui/lib/extensionCommandControls.ts`. They resolve the live App command table on every call,
then expose only built-ins carrying explicit public metadata. Counted movement reaches the same
command callback once with a normalized delta; it is never implemented as repeated synchronous
dispatch. Current-line alignment is also semantic: App raises an alignment request and `DiffPane`
resolves it against its private row geometry, so no renderer or scrollbox leaks into the API.
Extension commands remain private to prevent recursion and cross-extension execution.

The **Extensions** menu is generated from the registered extension commands, one
item per command grouped by extension, and is absent entirely when there are
none — which is why the visible menu list is derived from the menus record
(`buildMenuSpecs` in `packages/hunk/src/ui/components/chrome/menu.ts`) rather than fixed.

## VCS adapters

`packages/hunk/src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup,
detection, and operation dispatch. `packages/hunk/src/app/vcsCatalog.ts` composes bundled
registrations, while `packages/hunk/src/app/sessionBootstrap.ts` extends that catalog with
accepted user adapters and threads the same value through loading, reload, and
watch. Detection is uniform across tiers: nearest checkout wins, priority breaks
equal-distance ties, and an explicit `vcs` id owned by the catalog wins.

Provider implementations — command construction, spawning, error translation,
and exact-source reading — live entirely in the private `packages/hunk-{git,jj,sapling}`
workspaces and import the public `hunkdiff/extension` contract plus only explicit
provider-neutral `@hunk/vcs/*` implementation subpaths.
`packages/hunk/src/extensions/vcsPatchResult.ts` is
the one conversion boundary where a published `ExtensionVcsPatchResult`
becomes Hunk's internal diff model, including structural `too-large` source
results. `packages/hunk/src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent
bootstrap marker and also consults the available catalog; startup performs a
second root/config pass when a global, config-path, or CLI adapter recognizes a
repository unavailable to the bundled catalog.

`hunk log` follows the same boundary. Core/app and `src/ui/history/` own the built-in command,
validated graph planning, presentation, themes, paging, and child-process orchestration. The shared
`src/ui/session/` runner owns the process-level renderer/root lifetime, while its closed host routes
retained history and fresh review surfaces without giving either surface terminal ownership. The
selected adapter's public `history` capability owns traversal, filtering,
immutable revision and parent identities, structured decorations, and the declarative review action
for a selected item. The host treats those ids as opaque and never constructs provider revision
syntax or decides root/merge comparison semantics. History pages remain child-before-parent across
the full cursor, including page boundaries; the extension conversion boundary validates that
ordering before core or UI consumes it.

## Public contract rules

The authoring surface is the `hunkdiff/extension` export — a façade over
internal types, declared in `packages/hunk/src/extension-api/types.ts`. That module must
stay import-free: declaration emission ships every module the entry reaches,
so an import there publishes Hunk internals (`scripts/packaging/check-pack.ts` fails
the pack when it does, and typechecks every `docs/extensions.md` example as
a consumer). Shapes shared with internal code are declared there and
re-exported inward.

---

### Extension System Exploration (`docs/extension-system-exploration.md`)

# Hunk extensions: an exploration

> **Historical design note (July 2026).** This proposal preceded Hunk's implemented extension
> system. See the [extension guide](extensions.md) and
> [extension architecture](extension-architecture.md) for the current contract. The implementation
> now includes trust-gated TypeScript loading, named commands, panes, file views, VCS adapters,
> review controls, and API versioning.

The proposal modeled a JavaScript/TypeScript extension system on
[pi](https://pi.dev) (`@earendil-works/pi`) and Hunk's architecture.

## 1. How pi does it

pi is a terminal coding agent whose stated philosophy is "adapt pi to your
workflow without forking it". Its extension system is the main mechanism for
that, and it has produced a real ecosystem (LSP integration, plan mode,
subagents, usage dashboards, statuslines, git-worktree management, browser
automation, even minigames that run while tests execute).

The mechanics that matter for us:

- **An extension is one TS file (or folder) exporting a default factory:**

  ```ts
  export default function (pi: ExtensionAPI) {
    pi.registerCommand("recap", { run: async (ctx) => { ... } });
    pi.registerShortcut("ctrl+g", { run: ... });
    pi.on("turn_end", async (event, ctx) => { ... });
  }
  ```

  No manifest, no build step. The runtime executes TS directly.

- **Auto-discovery from trusted locations:** `~/.pi/agent/extensions/*.ts`
  (global) and `.pi/extensions/*.ts` (project-local, loaded only after the
  project is explicitly trusted). Additional sources via settings:
  `"packages": ["npm:@foo/bar@1.0.0", "git:github.com/user/repo@v1"]`, plus a
  `-e ./path.ts` flag for development and `/reload` for hot reload.

- **One capability-granting API object**, not global hooks. Everything an
  extension can do goes through `ExtensionAPI` / `ExtensionContext`:
  registration (`registerCommand`, `registerShortcut`, `registerTool`,
  `registerFlag`, renderers), lifecycle + pipeline events (`session_start`,
  `turn_start/end`, `tool_call` with block/mutate power, `context` filtering,
  `input` interception), UI services (`ctx.ui.select/confirm/input/notify`,
  status bar slots, widgets, custom footers, autocomplete providers), and a
  shared event bus for extension-to-extension communication.

- **Events are interception points, not just notifications.** `tool_call` can
  block or rewrite a tool invocation; `message_end` can replace a message;
  `input` can consume user input entirely. This is what lets extensions change
  _behavior_, not just decorate it.

- **Packages bundle extensions + skills + prompts + themes** and are installed
  with `pi install`. Notably, Hunk already participates in this ecosystem from
  the other side: our `package.json` declares `"pi": { "skills": ["./skills"] }`
  so pi users get the `hunk-review` skill automatically.

## 2. Why this fits Hunk

Hunk's positioning — "modern desktop diff tool in a terminal", review-first,
agent-adjacent — means users live in it during review and want it wired into
_their_ workflow: their editor, their forge, their VCS, their team's review
conventions, their agent. We cannot build all of that in core, and shouldn't.
The pi lesson is that a small, well-chosen API surface plus "coding agents make
writing plugins cheap" yields an ecosystem: most pi extensions are clearly
agent-written from a README and an afternoon.

The same force works for us twice over: agents can write Hunk extensions, and
Hunk is _used to review agent changesets_, so extensions that connect review to
agent workflows (post a comment back to the agent session, re-run the agent on
a rejected hunk) are the most natural third-party contributions imaginable.

## 3. Example extensions (use-case catalog)

Roughly ordered from "certain someone builds this in week one" to speculative:

- **Open-in-X**: a key that opens the selected hunk in `$EDITOR`, VS Code,
  IntelliJ, or a browser at the forge URL. (Core has one editor action today;
  everyone's variant differs.)
- **Copy-as**: copy the selected hunk as a fenced patch, a GitHub-suggestion
  block, or a permalink, ready to paste into a PR comment or Slack.
- **Forge review bridge**: publish accumulated review notes as a GitHub /
  GitLab / Gerrit review from inside Hunk; show existing PR review threads as
  notes beside the hunks they target.
- **Send-to-agent**: a shortcut that packages the selected hunk plus your draft
  note and sends it to a running pi / Claude Code session ("fix this"), using
  the daemon the same way `hunk session comment` does — in reverse.
- **Changeset transforms**: auto-collapse lockfiles and generated files,
  reorder files by review priority, hide vendored dirs — per-team policy as a
  10-line extension instead of N config flags.
- **Note templates / conventional comments**: `nit:`, `blocking:`, `praise:`
  prefixes with severity colors; team review checklists rendered as a note.
- **Extra VCS backends**: Mercurial, Fossil, Perforce — the `VcsAdapter`
  interface already exists; an extension just registers another one.
- **Context enrichment**: show `git blame` age, CODEOWNERS, CI status, or the
  Linear ticket for the branch in the file header; LSP hover for the symbol
  under the selected line.
- **Explain-this-hunk**: call the user's own model API key to annotate a hunk
  on demand, rendering the answer as an agent note (STML makes these render
  well).
- **Theme + language packs**: ship N custom themes and file-extension →
  language mappings as an installable package.
- **Review metrics**: time-per-file, notes-per-changeset dashboards; export a
  review session as a markdown report.

## 4. What Hunk already has (integration-surface audit)

An audit of the codebase found no intentional plugin mechanism, but several
seams in very different states of readiness:

**Nearly plugin-shaped already:**

- `vcsAdapters` (`packages/hunk/src/core/vcs/index.ts`) — a real adapter pattern: detection,
  per-operation handler maps, capability probing. Making the array appendable
  is almost the whole job for third-party VCS support.
- The session broker (`packages/session-broker*`) is generic over session
  info/state/message types by design, and the app installs its command
  dispatcher at runtime via `createHunkSessionBridge(handlers)` +
  `hostClient.setBridge(...)` (`packages/hunk/src/app/session/bridge.ts`). That bridge is
  the single clearest injection point for extension-provided session commands.
- Dynamic theme registration exists (`ensureSyntaxHighlightThemeRegistered` →
  Pierre's `registerCustomTheme`), but the config layer caps custom themes at
  one `"custom"` slot.
- Pierre's `setCustomExtension` (`packages/hunk/src/core/changeset/fileLanguage.ts`) is already used at
  import time for `.mts`/`.cts`; extending it to plugin-declared mappings is
  trivial.
- `StartupDeps` (`packages/hunk/src/app/startup.ts`) fully injects the startup pipeline
  (currently only tests use it), and unknown TOML keys are silently ignored, so
  an `[extensions]` config section is backward-compatible on day one.

**The gaps:**

- **No action registry.** Keyboard handling is a 650-line if/else ladder over
  `key.name` (`useAppKeyboardShortcuts.ts`); the same ~30 actions are
  re-enumerated as callback props there, in `buildAppMenus`, and in a
  hardcoded `HelpDialog` sections array. Extensions can't contribute a command
  or a keybinding because _core_ has no named-command concept to contribute to.
- **Generic CLI dispatch now has an extension fallback.** Built-ins still win
  through `parseCli`'s closed fast path, while an unknown top-level token loads
  enabled/trusted extensions and resolves `registerCliCommand` registrations.
  Handlers own raw nested args and leased I/O, and may exit or delegate once to
  a built-in plan without rerunning an unchanged factory prefix. Bare help stays
  static; unloaded command discovery remains a future manifest/listing problem.
- **Session actions take five files in lockstep** (`protocol.ts` union +
  version bump, `brokerServer.ts` switch + supported-actions list, `bridge.ts`,
  session `cli.ts`, `core/cli.ts` parser). A registry keyed by action name
  would collapse that — and is a prerequisite for extension session commands.
- **`MenuId` is a closed union**, though menu _entries_ are already data-shaped
  (`{label, hint, checked, action}`), so contribution points are cheap once
  actions exist.
- **No dynamic user-code loading anywhere**, and Hunk ships as a compiled Bun
  binary — see the open question below.

## 5. Proposed shape

Mirror pi's model closely; it is proven and our users overlap with pi's.

### Extension form and discovery

````ts
// ~/.config/hunk/extensions/copy-as-suggestion.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerAction({
    id: "copy-as-suggestion",
    label: "Copy as GitHub suggestion",
    keys: ["ctrl+shift+c"],
    menu: "file",
    when: (ctx) => ctx.selection.hunk !== null,
    run: async (ctx) => {
      const patch = ctx.selection.hunkPatchText();
      await ctx.clipboard.write("```suggestion\n" + patch + "\n```");
      ctx.ui.notify("Copied as suggestion");
    },
  });
}
````

- Discovery: `~/.config/hunk/extensions/*.ts` and `*/index.ts` (global, follows
  our existing XDG path logic in `packages/hunk/src/core/run/paths.ts`), `.hunk/extensions/`
  (repo-local, **trust-gated**, same posture as pi's project trust), explicit
  `[extensions] paths = [...]` in `config.toml`, and a `--extension <path>`
  dev flag. `--no-extensions` for a clean run and for bug triage.
- Per-extension config lives in the extension's own TOML table
  (`[extension.copy-as-suggestion]`), read through the existing layering engine
  so repo config can override user config exactly like core options.

### API surface, grouped by the seam it rides on

| Capability                                                                                                                          | Backed by                                                                         | Refactor needed                                                   |
| ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `registerAction({id, label, keys, menu, when, run})`                                                                                | new action registry feeding keyboard hook, `buildAppMenus`, `HelpDialog`          | the big one — see below                                           |
| `on(event, handler)` — `startup`, `changeset_loaded`, `selection_changed`, `note_added`, `note_saved`, `session_reload`, `shutdown` | emit from `AppHost` / `useReviewController` mutators                              | small                                                             |
| `transformChangeset(fn)`                                                                                                            | run after `loadAppBootstrap`, before first render, in `AppHost.reloadSession` too | small; must preserve sidecar ordering contract                    |
| `registerVcsAdapter(adapter)`                                                                                                       | `vcsAdapters` array                                                               | trivial                                                           |
| `registerTheme(theme)`                                                                                                              | generalize the single `"custom"` slot to N; Shiki side already dynamic            | small                                                             |
| `registerFileLanguage(ext, lang)`                                                                                                   | Pierre `setCustomExtension`                                                       | trivial                                                           |
| `registerSessionCommand(name, parse, handler)`                                                                                      | bridge dispatcher + broker action registry                                        | medium; collapses the 5-file lockstep first                       |
| `registerCliCommand(...)`                                                                                                           | command table replacing `parseCli` switch + help generation                       | medium; defer                                                     |
| `ctx.review` (read + navigate: files, selection, notes, `navigateTo`)                                                               | thin façade over `ReviewController`                                               | small, but must stay a façade — never hand out the raw controller |
| `ctx.ui.confirm/select/input/notify`                                                                                                | reuse `ConfirmDialog` + a toast/status line                                       | small                                                             |
| `ctx.exec`, `ctx.clipboard`, `ctx.cwd`, `ctx.changeset`                                                                             | plumbing                                                                          | small                                                             |

Two pi ideas worth copying verbatim: the **`ctx` object passed to every
handler** (capability access is explicit and testable, and there's no global to
monkey-patch), and **events that can intercept, not just observe** — e.g. a
`note_saving` event that can rewrite a note body is what makes conventional-
comment templates possible without core knowing about them.

Two pi ideas to _skip_ for now: provider/model registration (not our domain)
and custom message renderers (our equivalent — custom note renderables — fights
the STML deterministic-layout rule; extensions should emit STML markup instead,
which keeps measurement exact and theme-independent by construction).

### The enabling refactor: a named-action registry

Every UI-facing capability above lands on the same prerequisite: core actions
become data (`{id, label, hint, keys, menuId, isAvailable, run}`) in one
registry that `useAppKeyboardShortcuts`, `buildAppMenus`, and `HelpDialog` all
derive from. This is worth doing even if extensions never ship — it deletes the
existing triplication and the 650-line key ladder while giving each user-visible behavior one
owner. The same structure can support user keybinding remapping.

## 6. Suggested phasing

1. **Phase 0 — loader spike.** Prove that a compiled `bun build --compile`
   binary can `import()` user TS from disk (see open questions). Build the
   extension host: discovery, trust prompt for repo-local, error isolation
   (one broken extension logs a startup notice — the existing
   `startupNotices` channel — and is skipped, never crashes review),
   `--no-extensions`.
2. **Phase 1 — cheap registrations.** Themes (N custom), file languages, VCS
   adapters, `transformChangeset`, lifecycle events, `ctx.ui.notify`. No UI
   contribution yet; already enough for theme packs, lockfile-collapsing, and
   a Mercurial adapter.
3. **Phase 2 — the action registry.** Refactor core actions, then open
   `registerAction` with keys + menu + help integration, `ctx.review` façade,
   `ctx.ui` prompts. This unlocks the whole open-in-X / copy-as / send-to-agent
   family — the highest-demand tier.
4. **Phase 3 — protocol surfaces.** Session-command registry (after collapsing
   the 5-file lockstep), then CLI command table. Extension session commands
   need a capability story in the daemon handshake so `hunk session <ext-cmd>`
   fails cleanly against a session that lacks the extension.
5. **Phase 4 — distribution.** `hunk install npm:...` package support,
   hot-reload for extension development, and a docs page whose real audience is
   coding agents (pi ships an `extending-pi` guidance extension; our equivalent
   is a `hunk-extension-authoring` skill — same trick we already use for
   `hunk-review`).

## 7. Open questions

- **Compiled-binary loading.** The installed `hunk` is a `bun build --compile`
  snapshot. Runtime `import()` of arbitrary on-disk TS from a compiled binary
  is the load-bearing assumption behind "one TS file, no build step" — it must
  be spiked first. Fallbacks if it fails: transpile with `Bun.Transpiler` and
  evaluate, or require plain `.js`/`.mjs` for the binary while `bun run` keeps
  full TS. This decides Phase 0.
- **Security posture.** Extensions run with full user permissions (same as pi;
  same as any dotfile). The trust gate for repo-local extensions is the
  critical piece — reviewing a hostile repo's changeset must never execute
  that repo's code without an explicit prompt. This matters _more_ for Hunk
  than for pi: pointing a diff tool at untrusted code is a core use case.
- **API stability.** pi treats extension API churn as acceptable pre-1.0. We
  should version the API (`hunk.apiVersion`) from day one, and keep the
  surface minimal — everything in the table above is a façade we control, not
  a leaked internal type. Pierre types (`FileDiffMetadata`) should not appear
  in the extension API.
- **Windows.** Discovery paths and `ctx.exec` must stay portable per our
  cross-platform rules; nothing here is inherently Unix-only.
- **Does the OpenTUI embedding API make some of this moot?** `hunkdiff/opentui`
  already offers "extension by embedding" for people building their own review
  UIs. The answer is no — embedding serves builders of _other tools_;
  extensions serve users of _Hunk_. But the two should share the same façade
  types where they overlap.

## 8. Bottom line

The pi model transplants well. Hunk's architecture is closer to extension-ready
than expected in three places (VCS adapters, the session bridge, theme
registration) and blocked in one structural place (no named-action registry —
a refactor that is independently justified). The recommended path is the
phasing above, with the compiled-binary loader spike as the gating first step.

---

### Extensions (`docs/extensions.md`)

# Writing Hunk extensions

A Hunk extension entry is one TypeScript (or JavaScript) file that
default-exports a function. Hunk imports it at startup and hands it an API
object. An entry may stand alone or be declared by a folder's optional
`package.json` manifest; no build step is required.

```ts
// ~/.config/hunk/extensions/hello.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.on("startup", (_event, ctx) => {
    ctx.notify("Hello from my extension");
  });
}
```

> **The extension API is experimental.** Everything below works today, but the
> `hunkdiff/extension` surface may change in breaking ways between minor
> releases while it stabilizes against real third-party extensions. Breaking
> changes will be called out in release notes, and `hunk.apiVersion` identifies
> the surface an extension was written against.

Writing one with a coding agent? `hunk skill path hunk-extensions` prints a
bundled skill that maps the touchpoints below for agents, the way
`hunk skill path` does for reviewing.

## Where Hunk looks for extensions

Discovery runs group by group, alphabetically by resolved path within each
group — a folder extension's entries sort together, at the folder's own path.
The first occurrence of a resolved path wins, so a path you pass explicitly
keeps its origin even if the same file is also discovered somewhere else.

| Group | Source                                               | Trust                 |
| ----- | ---------------------------------------------------- | --------------------- |
| 1     | `--extension <path>` (repeatable)                    | runs immediately      |
| 2     | `[extensions] paths` in your user config             | runs immediately      |
| 3     | `~/.config/hunk/extensions/`                         | runs immediately      |
| 4     | `.hunk/extensions/` in the repo under review         | **prompts for trust** |
| 4     | `[extensions] paths` in the repo `.hunk/config.toml` | **prompts for trust** |

The two repo-local sources share a group number because they are one group:
both are repo-controlled, so they share a trust decision and their paths are
sorted together rather than one source being loaded ahead of the other.

A directory source matches `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.mjs` directly
inside it, plus one level of folder extensions, so a folder extension can keep
helper modules beside its entry file.

A folder is an extension if it declares its entry files in a `package.json`, or
failing that if it has an `index.{ts,tsx,js,jsx,mjs}` (in that preference
order, so a folder shipping both a source and a built entry resolves the same
everywhere). The manifest field is `hunk`:

```text
~/.config/hunk/extensions/my-ext/
  package.json          # {"hunk": {"extensions": ["./src/index.ts"]}}
  node_modules/         # bun install / npm install, right here
  src/
    index.ts            # the declared entry
    helper.ts
```

The manifest wins over the `index.*` fallback, and its paths resolve against the
folder. It may list more than one entry, in which case each entry loads as its
own extension in the order the manifest gives. Each one is identified by its
file stem; when stems collide, later entries receive a numeric suffix while
avoiding ids already claimed by other entries in the manifest.

Because the manifest is a real `package.json`, a folder extension may depend on
npm packages: declare them, install them into the folder's own `node_modules`,
and import them from the extension. This also works in the compiled Hunk binary;
package lookup follows the importing file and honors package export conditions
and subpaths, including transitive ES module dependencies outside the entry's
source directory. Optional dynamic imports can still catch missing-package or
blocked-export errors at execution time.

Use ES module imports (`import ... from "package"` or `await import("package")`)
for source dependencies. Synchronous `require()` of JavaScript/CommonJS files
handled by Hunk's asynchronous source-loading hook is not supported. Resolving
the correct `require` export does not remove that existing Bun limitation.

The `hunk` field may also state the minimum extension API version the folder
needs:

```json
{
  "name": "my-ext",
  "version": "1.0.0",
  "description": "What the extension does",
  "hunk": { "extensions": ["./src/index.ts"], "apiVersion": 3 }
}
```

A Hunk whose extension API is older than `apiVersion` refuses the folder with a
startup notice naming the version it would need, instead of failing somewhere
inside the factory with whatever error the missing surface happens to produce.
Omit it while you only use surface that has been around a while; declare it when
you depend on something recent (the current version is exported as
`HUNK_EXTENSION_API_VERSION` from `hunkdiff/extension` and handed to factories
as `hunk.apiVersion`). The standard `name`, `version`, and `description` fields
are how tooling and humans identify a shared extension, so fill them in on
anything you publish.

Pointing `--extension` or `[extensions] paths` straight at a directory works
either way: a directory that is itself a folder extension loads as that one
extension, so its helper modules stay helpers. A directory that is not is
treated as a directory _of_ extensions and scanned with the patterns above.

An extension's **id** is its file stem, or its folder name for
`<name>/index.ts`. A manifest that declares a single entry also keeps the
folder's name, whatever the entry file is called. The id is what
`[extension.<id>]` config tables key off, so moving a single-file extension into
a folder of the same name — or later giving that folder a manifest — keeps its
config working.

The id is also the namespace your extension owns: its commands are
`<id>.<commandId>` and its panes `<id>:<viewId>`. So the id has to be
spelled like a name — starting with a letter or digit, then letters, digits,
`-`, or `_`. A dot or a colon would make those composed ids ambiguous, and
`hunk`, `git`, `jj`, and `sl` are reserved for what Hunk ships. An extension
whose id breaks a rule is skipped with a startup notice naming the file; rename
it and it loads. If two discovery sources offer the same id, the first in
[source order](#where-hunk-looks-for-extensions) loads and the other is skipped the
same way, since one id cannot own two config tables.

`--no-extensions` disables user extensions for one run — nothing on disk is
read, let alone executed. Use it when triaging a bug. To disable one extension
instead, use its exact selection id in `[extensions] disabled` or pass
`--disable-extension <id>`; `--enable-extension <id>` is the explicit one-run
override for a configured disable.

`--extension` is explicit user intent: the file loads immediately, with no
trust prompt, even when the path points inside the repository under review.
Never pass a path you have not read — including one copy-pasted from a
repository's own README.

## Sharing and installing extensions

Extensions can be shared as Git repositories or npm packages.
`hunk extension install` installs either source into a managed directory under
`~/.config/hunk/extensions/installed/`, validates its extension entries, and
records the requested source plus its resolved Git commit or npm version.
Git and local-checkout sources keep the repository name as their install directory:

```bash
hunk extension install acme/hunk-word-diff          # GitHub shorthand
hunk extension install acme/hunk-word-diff@v1.2.0   # pin a tag, branch, or commit
hunk extension install git:codeberg.org/acme/ext    # any host; https:// is assumed
hunk extension install https://github.com/acme/hunk-word-diff.git
hunk extension install ~/dev/hunk-word-diff         # a local checkout, for testing
hunk extension install npm:@acme/hunk-word-diff     # npm latest
hunk extension install npm:@acme/hunk-word-diff@1.2.0
hunk extension install 'npm:@acme/hunk-word-diff@^1.2.0'
hunk extension install npm:@acme/hunk-word-diff@next
```

Managed installs load through the global source group — same origin, same
precedence, no trust prompt — because installing one is the explicit consent:
the install asks for confirmation (or `--yes`) after stating that extensions
run with your full user permissions. Only install sources you trust.

npm installs require Node.js and npm on PATH and honor normal npm registry and
credential configuration, including the launch project's `.npmrc`, user/global
configuration, and environment overrides. Project credentials are forwarded only
during acquisition, not saved into the managed install. Hunk forces local managed
installation even if npm is
configured globally. Each package has an isolated dependency workspace, and its
hoisted helpers receive the same host APIs and React instance as the entry.
Installation disables lifecycle scripts and automatic peer installation; publish
ready-to-load entries, and declare host-provided packages as peers or development
dependencies rather than runtime dependencies. Packages requiring install-time
builds or native dependency scripts are not supported automatically. If npm tries
to run a hook despite script suppression (including Git dependency `prepare`
hooks in some npm versions), Hunk blocks the script and fails the staged install
without replacing the previous version.

`hunk extension list` shows every managed install with its resolved version or
commit and requested source. `hunk extension update [name]` refreshes one install
(or all of them) from its recorded source. Git `@ref` pins keep their ref;
exact npm versions stay pinned, while ranges and tags are resolved again.
`hunk extension remove <name>` deletes the install and its record.
For npm packages, use the name shown by `list`, such as
`hunk extension update npm:@acme/hunk-word-diff` or
`hunk extension remove npm:@acme/hunk-word-diff`.

npm directories and single-entry extension IDs use a stable `npm-` name containing
a readable package-name prefix and its SHA-256 digest. This distinguishes scopes
and punctuation, stays filesystem-safe, and does not change with the version.
Use that ID in `[extension.<id>]` configuration; multi-entry manifests retain the
existing entry-derived IDs. Failed acquisition or validation leaves the previous
install intact. Managed installs never collide with
extensions you copied into `~/.config/hunk/extensions/` by hand, and the
installer refuses to overwrite an unmanaged directory of the same name.

### Publishing an extension

A publishable extension repository is just the folder-extension layout at the
repository root:

```text
hunk-word-diff/
  package.json          # name, version, description, hunk field
  index.ts              # or entries declared in "hunk": {"extensions": [...]}
  README.md
```

To publish one:

1. Give `package.json` a real `name`, `version`, and `description`, declare
   entries under the `hunk` field, and state `"hunk": {"apiVersion": N}` if you
   rely on recent API surface (see [the manifest](#where-hunk-looks-for-extensions)).
2. Keep it dependency-light. Declared `dependencies` are installed with
   `bun install` at install time when the user has `bun` on PATH; without it
   they get a warning and instructions. `react`, `@opentui/*`, and
   `hunkdiff/extension` come from the host at runtime and belong in
   `devDependencies` (types only), never `dependencies`.
3. Tag releases (`v1.2.0`) so users can pin with `@v1.2.0` instead of tracking
   your default branch.
4. Push the repository to any git host and add the **`hunk-extension`** GitHub
   topic so people can find it: every public repository with that topic shows
   up at <https://github.com/topics/hunk-extension>. Opening a pull request
   against `website/src/data/extensions.ts` also lists it on
   <https://hunk.dev/extensions>.

For npm publishing, the package must explicitly declare `hunk.extensions`.
Every declared entry must be a shipped file inside the package; check `npm pack
--dry-run` to verify the tarball includes the entries and their local imports.
Publish with `npm publish`; users can then install `npm:<package-name>`.

Before publishing, exercise the exact layout users will install:
`hunk extension install /path/to/your/checkout` installs from a local
repository, and `hunk diff --extension /path/to/your/checkout` loads it for one
run without installing anything.

## Bundled extensions

Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension,
and so are **`hunk gh` GitHub review commands**, the **built-in file-navigation pane**, the commit
and change-request info panes, and the **`/` content search** (`hunk.search.find` / `next` /
`previous`, with its match marks and status-row report). Provider implementations live in the private
`packages/hunk-{git,jj,sapling}` workspaces and are statically imported by
`packages/hunk/src/extensions/default/vcs/index.ts`. The GitHub command implementation lives in
`packages/hunk-gh` and loads through `packages/hunk/src/extensions/default/core/`; bundled UI
registrations live under `packages/hunk/src/extensions/default/ui/`. All register through the same
`hunk.registerVcsAdapter`, `hunk.registerCliCommand`, `hunk.registerPane`,
`hunk.registerCommand`, and `hunk.registerLineHighlighter` contract documented here, and their commands, highlighters, and
panes are composed ahead of yours; there is no private registration path.

Git exercises exact file sources, skipped-too-large placeholders, untracked files, watch plans,
and structured failures through the public adapter contract. Its package and boundary tests keep
those capabilities on the same registration path available to third-party adapters.

Bundled extensions differ from yours in three ways, all of them consequences of
being Hunk's own code:

- They are **statically imported**, so they load synchronously, before config
  resolution picks the session's VCS.
- They are **implicitly trusted**: no discovery, no trust prompt, and no
  `[extension.<id>]` config table.
- They stay loaded under `--no-extensions` and `[extensions] enabled = false`.
  Those switches exist to triage extensions _you_ installed. Independently selectable bundled
  capabilities use host-owned selection ids; the GitHub command extension is `hunk.gh`.

A bundled VCS factory failure becomes a load issue rather than crashing the session. Bundled UI
panes are required host code, so failure to register the expected panes aborts startup. The ids
`git`, `jj`, and `sl` are reserved as a result — see `registerVcsAdapter` below — and so is `hunk`,
the id the bundled files pane, the bundled search, and every built-in command are named under.
Process-cached bundled UI factories run with no config, so an interactive bundled command derives
session state from its context rather than closing over a review. Bundled core CLI factories instead
join the session-owned registry because delegated commands may retain resources until shutdown.

## Trust

Extensions run with your user permissions, exactly like a shell dotfile. That is
fine for extensions you installed yourself, and not fine for extensions that
came with a repository you are about to review — pointing a diff tool at
unfamiliar code is a normal thing to do, and it must never execute that code.

So repo-local sources are gated. The first time Hunk finds extensions in a
repository's `.hunk/extensions` (or repo-config `paths`), it skips them and asks:

```
Run this repository's extensions?

  This repository contains extensions in .hunk/extensions.
  Extensions run with your user permissions.

  enter/t trust · esc not now · n never
```

- **Trust** records the decision and reloads the session so the repo's
  extensions take effect immediately.
- **Not now** (also `Esc`) dismisses without recording anything; you will be
  asked again next time.
- **Never** records a denial so Hunk stops offering.

Decisions are stored per repository root in `~/.config/hunk/state.json`. The
prompt is a normal dialog over the review stream, not a gate in front of it:
you can dismiss it and keep reviewing.

Trust is keyed by the repo root's **path**, not by the repository's identity —
the same model VS Code workspace trust uses. If you delete a trusted checkout
and a different repository later occupies that path, it inherits the decision.
Clear the entry from `state.json` if that matters for a path you reuse.

## Failure isolation

A broken extension should not break review. An extension that fails to import,
has no default export, or throws from its factory is skipped, its partial
registrations are rolled back, and it becomes a startup notice in the footer.
A handler or transform that throws later is reported as a warning naming the
extension, and everything else keeps running. Event handlers receive frozen
copies of the changeset, so accidental mutation throws inside the handler
instead of corrupting the review.

This is crash containment, not a sandbox. Per-file `metadata` inside event
payloads is shared with the renderer for performance and is not frozen, and an
extension runs with your full user permissions — it can do anything your shell
can. The containment protects you from bugs, not from code you should not have
loaded in the first place. For reviewed files, prefer [`ctx.workspace`](#workspace-documents). It attributes
writes to the extension and asks for consent.

## The API

The factory receives one object. Registration calls are only valid while the
factory is running; Hunk seals the object afterwards so a deferred callback
cannot mutate the registry mid-session. Keep the factory registration-only:
start watchers, processes, connections, and other long-lived resources from
`startup`, and release them from `shutdown`. Extension-registry reloads create
new instances and run that shutdown/startup pair around the replacement.

One host-owned `ExtensionSession` holds active, provisional, and retiring registries for a
command lifetime. It revokes replaced authority at the review commit gate, drains bounded shutdown
handlers before terminal teardown, and prevents surfaces from independently replacing or retiring
the shared registry.

An interactive history workspace owns one extension instance for its complete
lifetime. Opening a commit review inside that workspace borrows the same
instance: the factory and `startup` do not run again, and returning to history
does not send `shutdown`. Review-specific `changeset_loaded` events still run
for each opened commit. The owning history workspace sends the one eventual
`shutdown` when it exits. This makes module-local clients and stores safe to
share deliberately between retained history and its commit reviews without a
review closing resources that history still uses.

Keep mutable review-generation data keyed by the identities in event payloads
or replace it on `changeset_loaded`; module scope is workspace state, not a
fresh namespace per opened commit. An embedded review cannot replace its
borrowed registry; extension replacement remains an operation of the owning
workspace. A true owner-driven extension-registry reload creates a new instance
and retires the replaced instance at that explicit ownership boundary.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `31`). Branch on it if you want
one file to support several Hunk versions. Version 31 adds optional read-only workspace status,
sibling inspection and status review planning; version 30 adds the opaque current review generation
in pane props; version 29 adds generation-scoped extension review presentation scopes; version 28
adds host-owned syntax highlighting for file-view code documents;
version 27 adds `ctx.selection.files`, the visible files in review order;
version 26 adds the status line (`ctx.statusLine` items and `ctx.prompts.line()` inline prompts);
version 25 adds Promise-returning watch signatures and watch cancellation; version 24 adds review
metadata to VCS patch results and short display revisions to commit descriptors; version 23 adds
canonical unified-layout fields while preserving the previous event vocabulary; version 22 adds
frame-derived pane preferred sizing,
non-resizable dynamic panes, and commit-history paint tokens; version 21 adds optional inclusive history-range review
planning and bounded comparison commit summaries; version 20 adds optional commit timestamps to review
metadata, pane clipboard actions, and the `theme.copyAction` paint token; version 19 adds provider-owned history
enumeration and review planning; version 18 lets lifecycle and custom-event handlers request
a host-owned review reload; version 17 adds structured review metadata to delegated patch
commands and projects it into pane availability and component props; version 16 adds pane-wide
`onActivate`; version 15 added `{ side, line }` to opted-in pane `currentLine`
paint; version 14 added structured `rangeEndpoints`
to two-revision VCS diff requests; version 13 added saved-note parent identities and
committed note-edit events; version 12 adds responsive fractional pane sizing; version 11 added
the `"dim"` line-highlight tone; version 10 added generic top-level CLI commands; version 9
added exact-filename and glob selectors to `registerFileLanguage`; version 8
added authoritative review snapshots to command handlers; version 7 added the
current source line to command selection snapshots. Version 6 added session behavior,
terminal-command observation, and live navigation/dialogs in event handlers;
version 5 added line highlighters and line-granular navigation (`revealLine`);
version 4 added keyboard modes and docked panes, with API-v3 sidebar names
remaining as deprecated aliases.

### `hunk.registerCliCommand(command, handler)`

Register a generic top-level command tree. The name must use lowercase kebab
case, cannot replace a built-in command or alias, and is global across loaded
extensions. Discovery order decides collisions: explicit flag, user-config path,
global/managed, then trusted repo extensions; the first claim wins.

```ts
hunk.registerCliCommand(
  { name: "greptile", summary: "Work with Greptile", usage: "<sync|review>" },
  async (args, ctx) => {
    if (args[0] === "sync") {
      await ctx.stdout.write("Synced.\n");
      return { kind: "exit", code: 0 };
    }

    await ctx.stderr.write("Preparing review…\n");
    return {
      kind: "delegate",
      argv: ["patch", "review.diff"],
      review: {
        kind: "change-request",
        provider: "GitHub",
        title: "Add structured review metadata",
        url: "https://github.com/acme/project/pull/123",
        id: "#123",
        repository: "acme/project",
        author: "octocat",
        base: "main",
        head: "review-metadata",
        state: "open",
        draft: false,
      },
    };
  },
);
```

The handler receives the frozen raw tokens below its top-level name plus
`ctx.cwd`, cooperative `ctx.signal`, byte-streaming `ctx.stdin`, and leased,
backpressure-aware `ctx.stdout`/`ctx.stderr`. It may use ordinary JavaScript APIs
to access networks, processes, services, and files. Return `{ kind: "exit",
code? }` with a status from 0 through 255, or delegate exactly once to a
built-in Hunk command.

A delegated built-in `patch` command may include a provider-neutral `review` descriptor. Its
`kind` is `change-request`, `commit`, or `comparison`; each exact shape combines bounded display
strings with an optional credential-free HTTPS URL. Hunk rejects unknown fields, control
characters, invalid types, unsafe URLs, fields over their byte limits, and descriptors over 4 KiB,
then copies and freezes the accepted value. `provider` and change-request `id` allow 256 bytes;
`repository`, `author`, `base`, `head`, and `revision` allow 512; `authoredAt` allows 128;
`title` and `url` allow 2 KiB. Change requests may also carry `state` (`open`, `closed`, or
`merged`) and boolean `draft`; commits may carry a parseable `authoredAt` date-time. Exit results and delegation to any built-in other than
`patch` cannot carry review metadata. An ordinary `hunk patch` has no descriptor.

The descriptor describes the review source rather than its diff contents: it stays on the app
bootstrap and does not enter changeset transforms or `ReviewDocumentV1`. Refreshing the same
file-backed patch preserves it, including watch and manual refresh; an explicit reload to a
different patch path or input kind clears it. Opening a commit from interactive `hunk log` attaches
a commit descriptor from the selected provider history row and preserves it while refreshing that
exact provider review request. Live-session list, context, and review JSON snapshots project the
same optional descriptor from registration metadata; it remains outside the semantic
review document and grants no remote reload or provider capability.

Delegation cannot target another extension command or change extension bootstrap
flags. Do not write stdout or read stdin before delegating; use stderr for
progress. Reading stdin is an exit-only workflow because even a pending read can
steal terminal input from the delegated command. Writers and stdin iterators
reject after the handler settles. SIGINT and SIGTERM abort
`ctx.signal`; handlers should stop promptly. Extensions are not sandboxed, so
direct process stream access cannot be enforced by these capabilities and must
be avoided.

Bare `hunk --help` remains static and does not load extensions. The extension
owns `hunk <name> --help` and receives `--help` unchanged.

`summary` and `usage` are what Hunk shows when a top-level token reaches
extension discovery but no extension claims it. That failure has already paid
for the registry, so it lists every loaded command rather than only naming the
token that was wrong:

```text
hunk: Unknown command: nosuchthing

Extension commands available here:
hunk cli-tools <status|review> [args...] — Demonstrate extension-provided CLI workflows
hunk gh <pr|commit|compare> <target> [--repo <owner/repo>] — Review GitHub-hosted changes
```

Both fields are collapsed to one sanitized line, so an extension cannot forge
host output with newlines or escape sequences.

Hunk's dependency-free [`@hunk/gh` bundled extension](../packages/hunk-gh/src/) is a complete
network workflow built on this contract. It discovers pull requests from the current branch, fetches
bounded GitHub pull-request, commit, and comparison diffs without the `gh` CLI, writes a temporary
patch with restrictive POSIX modes (and inherited temporary-directory ACLs on Windows), delegates
to the built-in `patch` command, and removes the patch on extension shutdown. Because it is bundled,
`hunk gh` remains available under `--no-extensions`; that switch disables user extensions only.
Disable it specifically with `[extensions] disabled = ["hunk.gh"]`, or restore it for one run with
`hunk --enable-extension hunk.gh gh …`.

### `hunk.configureSession(options)`

Request host-level behavior for the review session loading the extension. Use
`{ viewPreferences: "transient" }` for training, demos, and presentations that
deliberately exercise view controls but must never offer to save their final
practice state into the user's config. If any loaded extension requests it, the
shared session skips the save-view-preferences prompt on quit.

```ts
hunk.configureSession({ viewPreferences: "transient" });
```

The default is `{ viewPreferences: "default" }`. Like every registration-time
call, this must run synchronously while the factory is loading.

### `hunk.registerTheme(theme)`

Contribute one selectable theme. The object is the same shape as a
`[themes.<id>]` config table:

```ts
hunk.registerTheme({
  id: "midnight-review",
  label: "Midnight Review",
  base: "catppuccin-mocha",
  accent: "#7fd1ff",
  syntaxScopes: { "keyword.operator": "#7fd1ff" },
});
```

Theme ids must be lowercase words separated by `-` or `_` and cannot reuse a
built-in id. Config-defined themes always win over extension themes for the same
id; the loser is reported as a startup notice. Extension themes appear in the
selector after config themes, in load order.

### `hunk.registerFileLanguage(matcher, language)`

Map file extensions, exact filenames, or globs to an existing syntax-highlighting language. The
string shorthand registers a case-insensitive extension with or without its leading dot. Explicit
extension matchers use the same trimming, leading-dot removal, and lowercasing:

```ts
hunk.registerFileLanguage(".zig", "zig");
hunk.registerFileLanguage({ kind: "extension", value: "bzl" }, "python");
hunk.registerFileLanguage({ kind: "filename", value: "BUILD" }, "python");
hunk.registerFileLanguage(
  { kind: "glob", value: "generated/**/*.proto", target: "path" },
  "protobuf",
);
hunk.registerFileLanguage({ kind: "glob", value: "*.component", target: "basename" }, "typescript");
```

Filename and glob matching is case-sensitive on every platform. Exact filenames match a basename
at any directory depth. Globs use Bun's shell-style glob syntax and must explicitly target either
the basename or the review path exactly as Hunk decoded it. `/` is the review-path separator;
backslashes remain literal filename characters. Exact filename and glob values preserve leading and
trailing whitespace. Globs reject NUL and do not run against NUL-bearing decoded patch paths; exact
filename selectors can still address those paths. VCS review paths are normally repo-relative,
while generic patch input may carry an absolute path.

Hunk's reserved `.mts` and `.cts` mappings run first and cannot be overridden. Otherwise, exact
filenames take precedence over globs, which take precedence over extensions. The longest matching
extension wins, and later registrations win ties within each category. Direct attempts to register
those two reserved extensions are skipped with a notice.

This API selects a language already available to Pierre/Shiki. It does not load a new syntax
grammar; an unknown language remains plain text.

### Workspace status capability

API version 31 adds optional `ExtensionVcsAdapter.status`, independent of `history` and
review operations. Providers without it report unsupported for `hunk status`; detection never
falls back from a native JJ/Sapling workspace to Git status semantics. The bundled Git provider
implements the capability in `packages/hunk-git/src/status.ts`.

- `read({ targetPath? }, context)` returns an `ExtensionVcsStatusSnapshot` with `schemaVersion: 1`.
  `context.cwd` is the launch repository authority; a target may inspect only that repository's
  worktrees. A missing repository/worktree rejects. No process-wide cwd change, extension loading,
  fetch, checkout, staging or other mutation belongs in this capability.
- `readSiblings(snapshot, context)` returns bounded same-repository summaries. Worktree ids and
  paths must each be unique. Every row must share the inspected `repositoryId`; a row matching
  the current worktree's id or path must match both. These checks also apply to embedded
  `siblings: { state: "ready", value }` snapshots. Return an unavailable/error row, not zero
  changes, when a sibling cannot be read. The initial snapshot can publish
  `siblings: { state: "loading" }` before this slower scan completes.
- `reviewActions` supports only the semantic ids `staged` (index comparison) and `unstaged`
  (working-copy comparison), each at most once; ids are not opaque. Labels are provider-owned.
  A provider without an index offers only `unstaged`, with a suitable label such as "Working
  copy changes", and omits `paths[].index`. It need not invent Git staging semantics.
- `planReview(snapshot, actionId, context)` revalidates source/target identity and the opaque
  snapshot token, then returns `{ cwd, input: ExtensionVcsStatusReviewInput }` for a full
  comparison. This exported input type retains the exclusive `range` / `rangeEndpoints`
  variants but excludes pathspecs. The boundary explicitly rejects a `pathspecs` field, even an
  empty list or `undefined`, rather than silently stripping it. `cwd` must retain the inspected
  worktree path and `input.staged` must agree with the offered semantic action id. The UI passes
  only a provider-offered id; it does not construct revision expressions or narrow the comparison
  to a selected file. A live working tree can still change after planning; the token is not
  content attestation.
- Optional async `watchPlan(snapshot, context)` uses the existing watch-plan vocabulary for
  worktree and shared/per-worktree metadata. Hosts keep periodic polling for missed events,
  serialize/coalesce refreshes, cancel superseded reads and reject late generations.

Every asynchronous operation receives `context.signal` and must honor cancellation, bound its
I/O, and reap subprocesses. Status results allow at most 20,000 unique changed paths and 100
sibling rows. The defensive review-action collection limit is 32, but the supported ids permit
at most two unique actions. Ready operation arrays allow five entries. Watch plans allow 256
targets, four sources per target, and 20,000 ignored roots or entries per target. Each result
also has a 40,000-entry aggregate collection budget, counting nested arrays and outer rows/targets,
and an 8 MiB aggregate UTF-8 text budget across normalized fields. The boundary preflights collection
lengths before Zod traverses array elements and checks text before publishing copied results. Oversized results fail
explicitly; providers returning bounded sibling lists set `truncated`. Fields marked unknown/error
must not become clean, aligned, or zero-count results. The extension boundary validates and copies
snapshots before publishing them. Paths retain literal Unicode, tabs and newlines for
navigation/JSON; renderers escape control characters.
Providers without an index omit `paths[].index` rather than inventing Git staging states.

`changedPathCount` counts unique destination paths, including rename destinations and untracked
files. A partially staged path appears once with independent index/worktree states. Submodule
commit/tracked/untracked changes and conflicts stay explicit. No summed numstat is presented as
a net change; detailed comparisons belong in the existing diff view.

Fetch timestamps are optional facts with provenance, not observation timestamps. Git retains the
inspected worktree's local `FETCH_HEAD` mtime with `local-fetch-head-mtime` provenance in structured
facts: it is not authoritative for the named upstream and may describe a different remote or a
manually modified file. Human output therefore omits age; `(last fetched 18m ago)` may appear only
when provenance supports that claim. Hunk never fetches to populate it. Missing/unreadable metadata
remains unknown/error. See [workspace status](status.md) for command and scan policy.

### `hunk.registerVcsAdapter(adapter)`

Contribute an additional VCS backend. This is the same call Hunk's own bundled
Git, Jujutsu, and Sapling backends make.

```ts
hunk.registerVcsAdapter({
  id: "hg",
  name: "Mercurial",
  detect: (cwd) => (existsSync(join(cwd, ".hg")) ? { id: "hg", repoRoot: cwd } : null),
  operations: {
    "working-tree-diff": {
      async load(input, ctx) {
        return {
          repoRoot: ctx.cwd,
          sourceLabel: ctx.cwd,
          title: "Mercurial working copy",
          patchText: await runHgDiff(ctx.cwd),
          untrackedPaths: await listHgUnknownFiles(ctx.cwd),
        };
      },
    },
  },
});
```

The ids Hunk ships with — `git`, `jj`, and `sl` — are reserved. An adapter that
reuses one is skipped with a notice.

`operations` is optional and may implement any of `working-tree-diff`,
`revision-show`, and `stash-show`; an operation you leave out — or leaving the
map off entirely — produces a clear "not supported" error for that command
instead of a crash.

API version 19 adds the optional, read-only `history` capability used by the built-in `hunk log` surface. API version 21 adds optional inclusive range planning:

```ts
hunk.registerVcsAdapter({
  id: "hg-history",
  name: "Mercurial history",
  detect: () => null,
  history: {
    async open() {
      return {
        async read({ signal }) {
          signal?.throwIfAborted();
          return { commits: [], done: true };
        },
        close() {},
      };
    },
    planReview(commit) {
      return commit.parentRevisionIds[0]
        ? {
            kind: "revision-range",
            fromRevisionId: commit.parentRevisionIds[0],
            toRevisionId: commit.revisionId,
          }
        : { kind: "revision-show", revisionId: commit.revisionId };
    },
    planRangeReview({ newestCommit, oldestCommit }, _context, options) {
      const parent = options?.parentRevisionId ?? oldestCommit.parentRevisionIds[0];
      if (!parent) throw new Error("Resolve this provider's empty root baseline here.");
      return {
        kind: "revision-range",
        fromRevisionId: parent,
        toRevisionId: newestCommit.revisionId,
      };
    },
  },
});
```

The snippet above demonstrates static history production only; it is not a complete interactive
adapter. Add a `revision-show` operation for `revision-show` actions and a `working-tree-diff`
operation that accepts `rangeEndpoints` for `revision-range` actions before advertising interactive
opening. Otherwise Enter reports that the corresponding review operation is unsupported.

History is deliberately separate from patch-producing `operations`. The built-in host owns command
routing, graph planning, themes, terminal lifecycle, and static/interactive presentation. The
adapter owns every repository semantic: traversal and filtering, immutable identities, refs, and
review-planning decisions about roots, merges, ancestry, and direct endpoints. `planRangeReview` is
optional so older adapters remain compatible; without it, Hunk reports multi-commit opening as
unsupported rather than silently opening one commit. A range planner compares the chosen parent—or
provider-specific empty/root baseline—of `oldestCommit` directly with `newestCommit` and must not use
merge-base/triple-dot semantics. Hunk treats revision ids as opaque strings and never invents provider
revision syntax.

Commits must carry an immutable full `revisionId`, display id, ordered parent ids, subject, optional
message body, author (and optional email), ISO authored time, and structured ref decorations. The
optional `logicalId` identifies the same logical change across provider rewrites (for example, a
Jujutsu change id); Hunk treats it as metadata and continues to key graph and review operations by
immutable `revisionId`. A `head` decoration carries an optional `attachedLocalBranch`; use that field
rather than embedding an arrow or branch identity in its display label.

Every source must emit commits in **child-before-parent topological order**. If both a child and one
of its parents are included, the child appears first. This invariant spans the source's complete
lifetime: page boundaries do not reset it, and a parent returned on one page cannot be followed by
its child on a later page. Reads may return at most the requested limit and must distinguish a page
boundary from repository end with `done`. Hunk copies and validates every page, strips terminal
controls from display metadata, rejects duplicate revisions and parent-before-child output across
pages, forwards cancellation, and closes the source at EOF or failure.

The bundled Git and Jujutsu extensions implement this public capability today; Sapling currently
reports it as unsupported. Jujutsu supplies commit/change identities, bookmarks, tags, traversal,
and native merge-review semantics without routing through a colocated Git repository. Third-party
adapters use exactly the same contract.

Every operation `load` and `watchSignature` receives optional `context.signal`.
Use asynchronous subprocess APIs, pass cancellation through, and terminate plus
reap provider processes when it aborts; a synchronous spawn blocks Hunk's renderer
and prevents the abort handler from running.

A `load` result is patch text plus how to label it. Everything else on it is
optional, and each optional field buys one thing. API version 24 adds `review`:

| Field            | What it adds                                                       |
| ---------------- | ------------------------------------------------------------------ |
| `review`         | commit or comparison context above a revision-backed review        |
| `untrackedPaths` | files your VCS calls unknown, synthesized into added-file diffs    |
| `readFileSource` | exact whole-file contents, for context expansion and highlighting  |
| `sourceCacheKey` | stable source-snapshot identity for highlight reuse across reloads |
| `extraFiles`     | files reviewed outside the patch, including skipped placeholders   |

Use the same `ExtensionReviewDescriptor` accepted by delegated CLI reviews. Return a `commit`
descriptor when the operation resolves one reviewed commit, or a `comparison` descriptor when both
sides resolve to commits. Comparison `commits` are newest-first and bounded to eight entries; retain
the exact total in `commitCount` when known. Commit descriptors and comparison commit rows carry the
full immutable ID in `revision` for copying and should carry the provider-formatted short ID in
`displayRevision` for display. `displayRevision` remains optional on a single commit for extensions
built against an older API; Hunk abbreviates `revision` when it is absent. Omit `review` when either
side is working-copy, staged, stash, or otherwise cannot be identified accurately. Hunk validates,
copies, and freezes the descriptor before mounting it, then recomputes provider-supplied metadata on
reload so moving refs do not retain stale information.

`untrackedPaths` is the shorthand: list the repo-root-relative paths your VCS
reports as unknown and Hunk synthesizes the added-file diffs for you, skipping
binaries and files too large to render. Honor `input.options.excludeUntracked`
when you do, so `--exclude-untracked` still means what it says. The other two
are covered below.

#### Detection order

Detection prefers the **nearest** checkout: a Git repository nested inside a jj
workspace is reviewed as Git, whatever the priorities say. The same rule covers
your adapter — a Mercurial checkout inside a Git repository is reviewed as
Mercurial. `detectionPriority` only decides which backend wins when several
recognize the _same_ directory — the colocated case, where one working copy
carries two sets of markers.

| Adapter                  | Priority                                     |
| ------------------------ | -------------------------------------------- |
| bundled `jj`             | 200                                          |
| bundled `sl`             | 100                                          |
| bundled `git`            | 0 (`HUNK_VCS_DETECTION_BASELINE_PRIORITY`)   |
| your adapter, by default | -100 (`HUNK_DEFAULT_VCS_DETECTION_PRIORITY`) |

Higher is consulted first; equal priorities fall back to registration order.
jj and Sapling sit above Git because a colocated jj repository — or a Sapling
repository created with `sl init --git` — also carries Git metadata, and the
Git view is the wrong one.

The default puts your adapter below Git, so installing an extension never
silently changes how an existing repository is reviewed. Set
`detectionPriority` explicitly to outrank a shipped backend; it is your machine.

```ts
import { HUNK_VCS_DETECTION_BASELINE_PRIORITY } from "hunkdiff/extension";

hunk.registerVcsAdapter({
  id: "hg",
  name: "Mercurial",
  detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY + 10,
  detect,
});
```

Detection runs the same way for every adapter, whichever tier registered it:
the nearest checkout wins, `detectionPriority` breaks ties between adapters
that recognize the same root, and equal priorities fall back to registration
order. Hunk first resolves config and project root with the available catalog. If newly loaded
adapters change the detected project root, it reruns root and config resolution before loading the
session.

What detection never overrides is an explicit choice: a `vcs = "<id>"` in Hunk
config naming a backend this session loaded is honored as-is, however near a
checkout some other adapter finds. A repository-local adapter can bootstrap a
provider Hunk has never seen because `.hunk` itself establishes the project root;
global, config-path, and `--extension` adapters also participate in a staged
root/config pass before the review loads. When the final root only adds repo
candidates, Hunk extends the provisional registry instead of executing its
already loaded factories again. If repo config changes an existing extension's
factory config, Hunk sends that provisional instance `shutdown` before rebuilding it.

#### Watch support

Promise-returning `watchSignature` hooks and watch cancellation require API version 25.
Declare `"hunk": { "apiVersion": 25 }` in the extension manifest so older hosts refuse to
load it, or branch on `hunk.apiVersion` and keep a synchronous hook on older hosts.
Existing synchronous hooks remain supported.

`--watch` works through extension adapters. Each operation may add:

- `watchSignature(input, ctx)` — a fingerprint of the reviewed state, returning
  `string | Promise<string>`. Hunk awaits it and reloads when it changes. Prefer
  async I/O and honor `ctx.signal`, which aborts when observation closes.
- `watchPlan(input, ctx)` — the filesystem targets that cover that state, so
  Hunk reacts to events instead of polling on a timer.

```ts
watchPlan: (input, ctx) => ({
  coverage: "hybrid",
  targets: [
    {
      kind: "directory-tree",
      directory: ctx.cwd,
      ignoredRoots: [join(ctx.cwd, ".hg")],
      sources: ["worktree"],
    },
  ],
}),
```

`coverage: "hybrid"` promises the targets cover the reviewed state. Leaving
`watchPlan` out is equivalent to `poll-only` and still works — it just costs a
subprocess per tick.

#### Exact file sources

A patch carries the changed lines and a little context, and nothing else. If
your VCS can produce a file's _whole_ contents on each side, say so with
`readFileSource` and Hunk will expand context past the hunk, highlight against
the real file, and word-diff accurately.

```ts
async load(input, ctx) {
  // Pin the revisions while the operation loads, then close over them: by the
  // time Hunk asks for a file, nothing can have moved underneath it.
  const [oldRev, newRev] = await resolveHgRevisions(input, ctx.cwd);

  return {
    repoRoot: ctx.cwd,
    sourceLabel: ctx.cwd,
    title: "Mercurial working copy",
    patchText: await runHgDiff(ctx.cwd),
    sourceCacheKey: `${oldRev}:${newRev}`,
    readFileSource: async ({ path, previousPath, changeType, side }) => {
      if (side === "old") {
        return changeType === "new" ? null : hgCat(oldRev, previousPath ?? path);
      }
      return changeType === "deleted" ? null : hgCat(newRev, path);
    },
  };
}
```

Return `null` for a side that has no content — the old side of an added file, a
path the revision never contained — rather than throwing. Return
`{ kind: "too-large", maxBytes }` when fetching the source would exceed your
resource limit; Hunk shows expansion as unavailable without treating the result
as an extension failure. Hunk calls the reader
**at most once per file and side** and caches what it resolves, so you do not
need your own cache, and it never calls it for a file the diff reports as
binary. When equivalent reloads close over the same source base, return the same
opaque `sourceCacheKey` so Hunk can reuse its highlighted output. An equal per-file
patch plus that key must guarantee equal old/new source answers for the file; change
it when source state outside the patch changes. Omit it when the adapter cannot prove
stable identity and Hunk will invalidate conservatively. Leaving
`readFileSource` off is fine: Hunk falls back to the content the patch itself carries,
which renders the same diff with less context available.

#### Files outside the patch

`extraFiles` lists files to review that your `patchText` does not contain, in
the order they should appear. Each entry is one of two kinds, and Hunk builds
the diff model for both — you describe files, you never assemble them.

A **patch** entry is a file with its own one-file diff. Reach for it when your
VCS produces better text for a file than Hunk reading the working copy would —
its own binary detection, its own path quoting:

```ts
extraFiles: [
  {
    kind: "patch",
    path: "notes.md",
    patchText: await hgDiffOneFile("notes.md"),
    isUntracked: true,
  },
];
```

A **skipped** entry is a file Hunk should list but not render. Reviewing a
multi-hundred-megabyte generated file costs more than it is worth, so report the
file and why instead of producing a diff nothing will read:

```ts
extraFiles: [
  {
    kind: "skipped",
    path: "dist/bundle.js",
    reason: "too-large",
    changeType: "change",
    stats: { additions: 100_001, deletions: 0 },
    statsTruncated: true,
  },
];
```

`readFileSource` covers the patch entries too; a skipped entry has no content to
read, so it never gets a source reader.

`untrackedPaths` remains the shorthand for the common case: list the paths your
VCS calls unknown and Hunk synthesizes the added-file diffs from the working
copy, skipping binaries and files too large to render. Use `extraFiles` instead
only when your VCS renders those files better than a plain read would.

#### Moved lines

`input.options.colorMoved` is true when the user asked for move detection.
Hunk reads move classes back out of the patch itself, so emit ANSI-colored diff
text painting moved additions cyan and moved deletions magenta — what
`git diff --color-moved` produces — and those lines render as moved. This is
ordinary post-processing over whatever patch text an adapter returns, not a Git
special case. A backend with no notion of moved lines can ignore the option.

#### Failures the user can fix

Throw a `HunkExtensionUserError` when the problem is how Hunk was invoked rather
than a bug — no repository here, an unresolvable revision, a missing binary.
Hunk prints the message without a stack trace and lists the suggestions beneath
it. Anything else is reported as an unexpected error.

```ts
import { HunkExtensionUserError } from "hunkdiff/extension";

throw new HunkExtensionUserError("`hunk stash show` is not supported by Mercurial.", {
  suggestions: ["Use `hunk show <rev>` to review a commit instead."],
});
```

Hunk detects this structurally — an object whose `name` is
`"HunkExtensionUserError"` with an optional `suggestions` array of strings — so a
plain-JavaScript extension, or one bundling its own copy of the class, is
treated the same way. `HUNK_EXTENSION_USER_ERROR_NAME` is exported if you would
rather not hard-code the string. Hunk's own bundled Git, Jujutsu, and Sapling
backends raise their failures exactly this way.

### `hunk.registerPane(pane)`

Render a React component on the `left`, `right`, `top`, or `bottom` edge of the
review. Pair it with `registerCommand` so a key opens it:

```tsx
// ~/.config/hunk/extensions/flat-pane.tsx
import { useMemo } from "react";
import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension";

function FlatPane({ files, selectedFileId, theme, actions }: ExtensionPaneProps) {
  const ordered = useMemo(() => [...files].sort((a, b) => a.path.localeCompare(b.path)), [files]);

  return (
    <scrollbox scrollY={true} width="100%" height="100%">
      {ordered.map((file) => (
        <text
          key={file.id}
          content={` ${file.path}  +${file.stats.additions} -${file.stats.deletions}`}
          style={{
            fg: file.id === selectedFileId ? theme.accent : theme.text,
            bg: theme.panel,
          }}
          onMouseDown={() => actions.selectFile(file.id)}
        />
      ))}
    </scrollbox>
  );
}

export default function (hunk: HunkExtensionAPI) {
  hunk.registerPane({
    id: "flat",
    title: "Flat files",
    placement: "right",
    component: FlatPane,
  });
  hunk.registerCommand({ id: "toggle-flat", title: "Toggle flat pane", key: "ctrl+f" }, (ctx) => {
    ctx.panes.toggle("flat");
  });
}
```

`placement` defaults to `"left"`. Left/right panes use `width`; top/bottom panes
use `height`. Both accept `{ preferred, min?, max?, fraction? }`; equal bounds
make a fixed pane. Defaults are `{ preferred: 34, min: 22 }` columns and
`{ preferred: 8, min: 3 }` rows.

`fraction` opts into live responsive sizing until the user drags the divider. It
must be greater than `0` and at most `1`; Hunk rounds that fraction of the full
host body width or height to a terminal cell, then applies `min`, `max`, and the
space required by the review. `preferred` remains the fixed-cell target when
`fraction` is omitted. A divider drag establishes a session-local cell override:
later terminal shrink may clamp it temporarily, and expanding restores it.
Panes without `fraction` retain their fixed preferred startup size. Folder
extensions that use `fraction` should declare `"hunk": { "apiVersion": 12 }` in
their manifest.

`preferredSize(context)` can derive that automatic cell target from current
review facts. Hunk invokes it synchronously with the same context as
`available`, clamps its positive whole-number result to `min`/`max`, and still
lets a session-local divider drag take precedence. Set `resizable: false` when a
dynamic pane should track that target without exposing a divider. These options
require API version 22.

Use `defaultOpen` to open a pane initially, `replaces: "hunk:files"` to replace
the initial files pane (and override `defaultOpen`), and `available(context)` to
hide it conditionally. One pane may replace each named target; the first
registration owns that slot and later claims are skipped with a warning.
`replaces` may also name another pane by its fully qualified
`"<extensionId>:<paneId>"` key, and Hunk follows those replacement chains.
Both `available(context)` and the mounted component receive `review`: immutable
metadata supplied by a delegated patch command or an interactive history selection, or `null` for
ordinary reviews. The bundled `hunk:review-info` top pane uses this to show change-request and
commit identity without taking any rows when no supported descriptor exists. Pane extensions that read
`review` should declare `"hunk": { "apiVersion": 17 }` in their manifest so older Hunk versions
refuse them cleanly instead of mounting with an incomplete prop contract.

`onActivate()` observes a primary mouse press anywhere in the pane's content,
including content nested in a `<scrollbox>`. Use it to focus an extension-owned
editor or update pane-local active state without adding mouse handlers to every
row. Hunk does not stop propagation or prevent the press, so extension-local
mouse behavior can continue. Other mouse buttons do not activate the pane. A
thrown or rejected callback is contained and reported as an attributed warning.

`hunk:files` is a named role, not a left-edge location. The
`hunk.view.toggleFilesPane` command (`s` by default) and **View → Files pane**
follow the resolved owner of that slot, whether the replacement is on the left,
right, top, or bottom. They toggle only that owner; independently registered
panes keep their own open state. User remaps and unbindings of
`hunk.view.toggleFilesPane` apply to the resolved slot in the usual way. The
former `hunk.view.toggleSidebar` id remains a compatibility alias.

`currentLine: true` opts into the selected-row painter. `currentLine.render(side, width)`
paints one side as a clipped row; `currentLine.side` and `currentLine.line` are
the same public source address command handlers see on `ctx.selection.currentLine`
(context rows use Hunk's canonical new-side). The installable
[Hunk Lens](https://github.com/modem-dev/hunk-lens) extension uses the painter; a
blame or diagnostic pane can use the address without waiting for a keypress.
Install the lens with `hunk extension install modem-dev/hunk-lens`.

Import `react` normally — Hunk serves its own React instance to extension files
at import time, so hooks, context, and JSX all run on the reconciler drawing the
rest of the app. **Never bundle or vendor a copy of React into an extension**: a
second React means a second hooks dispatcher, and the component will fail to
render. OpenTUI elements (`box`, `text`, `scrollbox`, ...) are plain intrinsic
elements and need no import.

The component receives fresh props as the app changes:

| Prop                | What it is                                                                                                                                                                |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `review`            | immutable review-source metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews                                                            |
| `reviewGeneration`  | opaque current generation string for `actions.setPresentationScope`, or `null` before the review settles                                                                  |
| `files`             | the visible reviewed files, review-stream order, filtered, frozen views (each carries `changeType`, `statsTruncated`, and `hunks` summaries beside the usual file fields) |
| `selectedFileId`    | the selected file, or `null`                                                                                                                                              |
| `selectedHunkIndex` | the selected hunk within that file, or `null`                                                                                                                             |
| `placement`         | the accepted terminal edge                                                                                                                                                |
| `width`             | exact terminal columns in the host-owned rectangle                                                                                                                        |
| `height`            | exact terminal rows in the host-owned rectangle                                                                                                                           |
| `currentLine`       | selected-row painter plus `{ side, line }` when the registration opts in, otherwise `null`                                                                                |
| `theme`             | hex color tokens from the active theme, updated on theme switch                                                                                                           |
| `keybindings`       | the current command bindings, resolved from defaults and the user's `[keybindings]` table                                                                                 |
| `actions`           | navigation, clipboard, and notifications the pane may trigger                                                                                                             |

API-v3 sidebar names remain as deprecated aliases: use `registerPane`,
`ExtensionPane*`, `ctx.panes`, and `replaces: "hunk:files"` in new code.

`actions.selectFile(fileId)`, `actions.selectHunk(fileId, hunkIndex)`, and
`actions.revealLine(fileId, side, line)` route through the same review
controller as the built-in files pane and the keyboard shortcuts, so the review
stream scrolls, selection updates, and the `selection_changed` event fires
exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's
OSC 52 clipboard integration and returns `false` when unavailable. Extensions that call it or read
`theme.copyAction` should declare `"hunk": { "apiVersion": 20 }` in their manifest. `actions.notify(message,
type?)` shows a toast attributed to your extension. To scope from a pane mouse
handler, use `props.reviewGeneration` as the scope's `generation` and the
visible file `id` plus hunk indexes as its targets:

```ts
import type { ExtensionPaneProps } from "hunkdiff/extension";

/** Focus the first hunk of the first visible file from a pane action. */
export function focusFirstHunk(props: ExtensionPaneProps): boolean {
  const file = props.files[0];
  const targetHunk = file?.hunks?.[0];
  if (!props.reviewGeneration || !file || !targetHunk) return false;

  return props.actions.setPresentationScope({
    generation: props.reviewGeneration,
    files: [{ fileId: file.id, hunkIndexes: [targetHunk.index] }],
  });
}
```

Guard the call when `reviewGeneration` is `null`; the host rejects stale or malformed
requests. An action given a file id
that is not currently visible is refused with a warning rather than corrupting
the selection. A pane's `actions` carry the same navigation methods a command
handler's [`ctx.navigation`](#navigating-the-review) does, with the same
guarantees.

The three hunk surfaces line up by design: each file's `hunks` lists public
`ExtensionDiffHunk` summaries (`index`, the `@@` header, inclusive old/new
line spans) in render order, `selectedHunkIndex` reports the same index, and
`actions.selectHunk(fileId, hunkIndex)` accepts it. That is everything a hunk
checklist, a per-hunk progress view, or an agent-annotation navigator needs —
match an annotation's `oldRange`/`newRange` against the summaries' spans to
find its hunk — without touching the opaque `metadata`.

A component that owns a key event should ask the injected `keybindings`
manager about a **command id**, rather than hard-coding the command's default
chord. Like Pi's injected `KeybindingsManager`, this keeps local component
behavior synchronized with the user's remaps and unbindings:

```ts
import type { ExtensionKeyEvent, ExtensionPaneProps } from "hunkdiff/extension";

export function handlePaneKey(props: ExtensionPaneProps, key: ExtensionKeyEvent) {
  const nextFile = props.files[1];
  if (nextFile && props.keybindings.matches(key, "hunk.review.nextFile")) {
    // The user may have remapped this from `.` to another chord.
    props.actions.selectFile(nextFile.id);
  }
}
```

`keybindings.getKeys(commandId)` returns the current chord list for a label or
hint; unknown and unbound commands return an empty list. `matches(key,
commandId)` returns `false` for those commands too. The manager includes both
Hunk commands and extension commands under their documented ids, and its key
event argument is structural — OpenTUI's `KeyEvent` works directly.

`matchesKey`, `parseKeyChord`, and `matchesKeyChord` remain exported for
extension-local keys that intentionally are not commands. Prefer a named
command whenever a shortcut should be user-remappable.

Hunk owns pane geometry, dividers, and responsive omission. Render failures are
contained to that pane; a failed `hunk:files` replacement restores file
navigation.

Props carry the pane's exact `width` and `height`. Use a `<scrollbox>` ref for
scroll position and selection following; Hunk serves the matching
`@opentui/core` instance to extensions.

#### Scrolling: the scrollbox ref contract

The one behavior a list pane always ends up needing is following the
selection. Give your rows stable `id` props, hold a ref to the scrollbox, and
scroll the selected row into view from an effect:

```tsx
import { useEffect, useRef } from "react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ExtensionPaneProps } from "hunkdiff/extension";

function HunkList({
  files,
  selectedFileId,
  selectedHunkIndex,
  theme,
  actions,
}: ExtensionPaneProps) {
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);

  // Follow policy is deliberately yours: the host never scrolls a pane it
  // cannot see into, so decide here when (and whether) to follow.
  useEffect(() => {
    if (selectedFileId !== null) {
      scrollRef.current?.scrollChildIntoView(`row-${selectedFileId}-${selectedHunkIndex ?? 0}`);
    }
  }, [selectedFileId, selectedHunkIndex]);

  return (
    <scrollbox ref={scrollRef} width="100%" height="100%" scrollY={true} focused={false}>
      {files.flatMap((file) =>
        (file.hunks ?? []).map((hunk) => {
          const selected = file.id === selectedFileId && hunk.index === selectedHunkIndex;
          return (
            <box
              key={`${file.id}:${hunk.index}`}
              id={`row-${file.id}-${hunk.index}`}
              style={{ width: "100%", height: 1 }}
              onMouseUp={() => actions.selectHunk(file.id, hunk.index)}
            >
              <text
                content={` ${file.path}  ${hunk.header}`}
                style={{ fg: selected ? theme.accent : theme.text }}
              />
            </box>
          );
        }),
      )}
    </scrollbox>
  );
}
```

The ref surface this recipe stands on is the exact one the built-in files pane
runs on:

- **`scrollChildIntoView(id)`** scrolls the descendant with that `id` prop
  into view.
- **`scrollTop`** and **`viewport.height`** read the current scroll offset and
  the scrollbox's live viewport rows. A read before the first layout pass
  reports `0`, so viewport-dependent code belongs behind the events below
  rather than a bare mount effect.
- **`verticalScrollBar.on("change", handler)`**,
  **`viewport.on("layout-changed", handler)`**, and
  **`viewport.on("resized", handler)`** report scrolling and pane resizes;
  unsubscribe with the matching `.off` in your effect's cleanup.

That is enough to window a long list yourself: the built-in files pane renders
only the rows near the viewport, plus spacer boxes sized from those same
reads (its render-window helper is host code, but nothing it computes needs
anything beyond this surface — `useTerminalDimensions` from `@opentui/react`
serves as its pre-first-layout viewport estimate).

One honest caveat: this contract rides on OpenTUI's renderable API, served at
whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself.
The built-in files pane uses the same calls, so changes that break this contract
break Hunk first. Keep scroll handling small and behind your own helpers.

Its implementation lives in `packages/hunk/src/extensions/default/ui/sidebar/` and serves as
the reference for third-party panes.

#### Pane state from events

Lifecycle handlers run outside React, but a pane component only rerenders
when React sees a change. The recipe that connects them is a module-local store
read through `useSyncExternalStore`: the event handler updates the store, and
any mounted component subscribed to it rerenders — while the store keeps
accumulating even when the pane is closed.

```tsx
import { useSyncExternalStore } from "react";
import type { HunkExtensionAPI } from "hunkdiff/extension";

let viewedPaths: ReadonlySet<string> = new Set();
const listeners = new Set<() => void>();

function markViewed(path: string) {
  if (viewedPaths.has(path)) return;
  viewedPaths = new Set(viewedPaths).add(path); // new reference, so React sees the change
  for (const listener of listeners) listener();
}

function useViewedPaths() {
  return useSyncExternalStore(
    (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    () => viewedPaths,
  );
}

function ViewedCount() {
  const viewed = useViewedPaths();
  return <text content={`${viewed.size} files viewed`} />;
}

export default function (hunk: HunkExtensionAPI) {
  hunk.on("file_viewed", ({ file }) => markViewed(file.path));
  hunk.registerPane({ id: "progress", component: ViewedCount });
}
```

Snapshots must be immutable — replace the set instead of mutating it, so
`useSyncExternalStore` can compare references. Storing state in a hook inside
the component instead would lose it every time the pane closes and unmounts.

### Status line

The bottom status row — where Hunk shows the file filter, notices, and the
keyboard-mode badge — is a host-owned surface extensions write to through two
small capabilities: persistent **items** and one inline **prompt**.

This example counts literal, non-overlapping matches in the selected file's
patch text (including patch headers), not its whole source document. The right
item counts `file_viewed` events, including revisits and reloads, rather than
unique files.

```ts
import type { ExtensionDiffFile, HunkExtensionAPI } from "hunkdiff/extension";

/** Count literal, non-overlapping occurrences in the selected patch. */
function countMatches(query: string, file: ExtensionDiffFile | null): number {
  if (!query || !file) return 0;
  return file.patch.split(query).length - 1;
}

export default function (hunk: HunkExtensionAPI) {
  let viewed = 0;

  hunk.registerCommand({ id: "find", title: "Search diff content", key: "ctrl+f" }, async (ctx) => {
    const query = await ctx.prompts.line({ prefix: "/", placeholder: "literal text" });
    if (query === null) return;

    const hits = countMatches(query, ctx.selection.file);
    ctx.statusLine.set({
      id: "status",
      spans: [
        { text: `[${hits}] `, tone: "accent" },
        { text: query, tone: "muted" },
      ],
    });
  });

  hunk.on("file_viewed", (_payload, ctx) => {
    viewed += 1;
    ctx.statusLine.set({
      id: "viewed",
      spans: [{ text: `${viewed} viewed` }],
      alignment: "right",
    });
  });
}
```

**Items** are declarative text, not components. `ctx.statusLine.set(item)`
sets or replaces one item and `clear(id)` removes it; ids are scoped to your
extension. `spans` use the same symbolic vocabulary as host-rendered file-view
rows — `text`, an optional `tone` (`muted`, `accent`, `accent-muted`, `syntax`,
`added`, `removed`), and optional `attributes` (`bold`, `italic`, `underline`,
`strikethrough`) — so Hunk measures them without a theme and paints them with
the active one. `alignment` defaults to `"left"`; right items sit beside the
host badge. When the row overflows, the lowest-`priority` items (default `0`)
are dropped whole, newest first among equals, and the last survivor is
truncated with an ellipsis; the badge is never dropped. A set item keeps the
row on screen, exactly like a non-empty filter does, so clear items that
should not cost a row while idle. Items persist across ordinary content reloads
and clear when the extension registry is replaced or the review unmounts.

**Prompts** are promise-shaped. `ctx.prompts.line({ prefix?, placeholder?,
initial?, onChange? })` draws a real input with a cursor on the status row and
resolves the submitted text, or `null` on cancel. `prefix` is painted before
the input and is not part of the value; `initial` is where the field starts
(use it to reopen with the last query); `onChange` is called on every edit for
consumers that react while the user types. Enter resolves; Escape clears a
non-empty buffer first and cancels second — the same two-step Escape the file
filter has. While a prompt is open it owns typing the way the filter does:
after dialogs and menus, before file-view and session keyboard modes and the
command table, so a bound key is text rather than a command. One prompt is
open at a time; a second request queues behind the first, across extensions
too. A session reload cancels open and queued prompts, and a request during
teardown resolves `null` immediately. Prompts from installed extensions carry
the `ext <your-id>` marker before the prefix, like toasts and dialogs.

Where the controls appear: command handlers get `ctx.statusLine` and
`ctx.prompts`; event, bus, and keyboard-mode handlers get `ctx.statusLine`
only. The factory object gets neither, so every write belongs to a handler
whose lifetime Hunk can scope. A malformed item (blank `id`, non-array `spans`,
a span without string `text`, an unknown tone) throws from `set`; malformed
prompt options reject the promise; a throwing `onChange` is reported once and
the prompt continues.

### `hunk.registerFileView(view)` (experimental)

A file view is an alternate **host-rendered** presentation of one file in the
same top-to-bottom review stream. It is not a whole-file React component: Hunk
owns row measurement, scrolling/windowing, hunk navigation, and fallback to
Pierre's raw diff. A constrained, experimental
[fixed-height JSX row POC](file-view-jsx-poc.md) lets individual validated rows
paint OpenTUI content without taking over that geometry. Raw is always the
default; users select a matching view from **View** for the selected file.
Rows may bind themselves to exact old/new source ranges so Hunk can insert its
own inline review-note cards without giving the extension note contents or
geometry.

The installable
[`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/)
uses this contract for a parsed Markdown preview. It is intentionally not bundled
or loaded by default; copy the folder into `~/.config/hunk/extensions/`, install
its dependency there, and its View entry and `F8` command become available.

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerFileView({
    id: "plain-markdown",
    title: "Plain Markdown",
    matches: (file) => file.path.endsWith(".md"),
    async layout(input) {
      const document = await input.readDocument("new");
      if (!document || document.length > 100_000) return null;

      const sourceLines = (document.endsWith("\n") ? document.slice(0, -1) : document).split("\n");
      const rows = sourceLines.map((text, index) => ({
        id: `line:${index + 1}`,
        spans: [{ text: text || " " }],
        sourceRanges: [{ side: "new" as const, range: [index + 1, index + 1] as const }],
      }));
      if (rows.length === 0) return null;

      return {
        rows,
        hunkRows: (input.file.hunks ?? []).map((hunk) => ({
          startRow: Math.max(0, (hunk.newRange?.[0] ?? 1) - 1),
          endRow: Math.min(rows.length - 1, (hunk.newRange?.[1] ?? 1) - 1),
        })),
      };
    },
  });
}
```

`layout` receives one readonly input containing `file`, `width`, `signal`,
`changes`, and `readDocument`. `input.file` is the same frozen public
`ExtensionDiffFile` panes receive. `input.changes` exposes typed added and
removed ranges without Pierre metadata;
complete old/new hunk ranges remain available through `input.file.hunks`.
`readDocument("old" | "new")` is lazy and cached by Hunk; it resolves exact
text or `null` when that side is absent, unavailable, too large, or fails to
load. Never treat `null` as an exception: return `null` from `layout` to keep
raw diff active.

Layouts use an omitted tone for ordinary text and generic symbolic tones
(`muted`, `accent`, `accent-muted`, `syntax`, `added`, `removed`) plus optional
terminal attributes (`bold`, `italic`, `underline`, `strikethrough`). Hunk
resolves those primitives only while painting, so the host does not learn the
extension's content format and measurement remains theme-independent. Every
parsed hunk needs one in-bounds, inclusive `hunkRows` entry at the same array
position as `input.file.hunks`.

#### Host-owned syntax paint (API v28)

A file view may declare complete `codeDocuments` and map individual symbolic spans into them.
Extensions provide code and coordinates, never colors, Shiki/Pierre objects, HAST, grammars, or
terminal tokens. Hunk resolves the language and active syntax theme, tokenizes the complete
document so multiline comments, strings, templates, and Markdown fences retain lexical context,
and projects only the ranges demanded by the mounted row window. Highlighting arrives
asynchronously and is paint-only: it cannot change retained text, wrapping, row height, layout
generation, note placement, navigation, selection, or scroll position.

```
/* Detailed source-code truncated for AI context efficiency. */
```

`ExtensionFileViewCodeDocument` has a layout-local unique `id`, complete `text`, and optional
`language`. An omitted language uses the reviewed file's host-detected language. An explicit
language is still resolved by Hunk; an unavailable grammar leaves ordinary symbolic paint. A
span's `syntax` is an `ExtensionFileViewSyntaxReference`: `documentId`, a one-based `line`, and an
optional zero-based, half-open UTF-16 `range`. Omit `range` to reference the complete line. This
supports generated/transformed documents as well as reviewed old/new source, and one split-style
row may independently reference old and new documents. Keep gutters, blame metadata, diff markers,
ellipses, separators, and padding in separate non-syntax spans.

Hunk normalizes CRLF and lone CR to LF without inventing a line after a final newline, strips
terminal controls line by line, and snapshots the resulting terminal-safe document. The retained
terminal-safe `span.text` must exactly equal the referenced complete line or slice. References use
JavaScript UTF-16 columns before terminal-cell conversion, so astral code points occupy two column
units. Horizontal tabs remain one UTF-16 unit and pass through to OpenTUI, which displays each tab
at its fixed two-cell width. If sanitization would make the authored span and document slice differ,
the layout is rejected rather than guessing an offset.

When syntax succeeds, its token foreground overrides the span's `tone`; token gaps retain the tone,
and authored `attributes` apply to every projected run. Hunk continues to own selected-hunk and
current-row backgrounds. `added` and `removed` remain their existing semantic foreground tones;
code documents do not opt into raw-diff backgrounds. Custom row components never receive syntax
colors or token data: only their symbolic fallback spans can use `syntax`, while successful custom
component output remains untouched.

`syntax` and `sourceRanges` are deliberately independent. Syntax references address paint and may
appear outside hunk rows; they never establish hunk ownership, note placement, navigation aliases,
or source provenance. `sourceRanges` retain the exact note/navigation semantics below.

Code-document validation is bounded to 64 documents, 1,000,000 aggregate UTF-16 code units, and
10,000 aggregate normalized lines per layout. Highlighting applies the same UTF-16 input and
normalized-line ceilings, requires every tokenized line to stay below 1,000 UTF-16 code units, bounds
compact output and completed caches by bytes and entries, and admits at most 16 unique outstanding
document jobs. Identical requests share one job; subscribers to that same job are not subject to a
separate numeric ceiling. Hunk only starts work when a visible/halo row demands a document, cancels
subscribers that leave demand, and discards stale theme/file/view/layout results. Unsupported
languages, oversize input, queue pressure, cancellation, worker failure, and tokenization failure
all retain the original symbolic FileView content. By contrast, invalid layout data or unavailable
native text measurement fails layout preparation and falls back to the raw diff, because Hunk
cannot safely retain a view without exact geometry. Folder extensions using `codeDocuments` or
`syntax` must declare `"hunk": { "apiVersion": 28 }`.

A row's optional `sourceRanges` contains inclusive, one-based exact-source
bindings such as `{ side: "new", range: [12, 18] }`. Hunk reads only the bound
source sides, verifies every range is in bounds, rejects overlapping ranges on
the same side across rows, and requires each bound row to belong to exactly one
`hunkRows` extent. One source line and one bound row therefore resolve to one
presentation/hunk target. Inline notes anchor by their existing preferred-side start
line; Hunk renders the bound file-view row first and inserts its note cards afterward. Placement is
**all-or-raw** per file: if any visible note is range-less or unbound, Hunk temporarily renders
the complete raw diff rather than guessing or dropping review data. The stored
presentation selection returns when the note layer is hidden or the mapping
becomes resolvable. Draft note editing remains raw-only.

Invalid, oversized, cancelled, or throwing layouts are isolated with one
warning per concrete extension registration and fall back to raw diff. Rapid width changes are coalesced, and Hunk never paints
geometry measured for a stale width. An experimental custom row keeps symbolic
fallback spans and declares its fixed painter
atomically as `component: { height, render }`. Painter props include the same
curated semantic `theme` palette as custom panes. It updates live at paint
time without entering `layout` or changing deterministic geometry. If painting
fails, the fallback spans are clipped to that same declared height rather than
changing stream geometry. Custom rows are non-focusable
paint surfaces: registered commands are their supported keyboard path. A
cooperatively delivered, handled left-button mouse-up may act and stop
propagation, while wheel, drag, and unhandled input remain host-owned. Hunk
makes no portal, renderer, focus, or input-delivery guarantee; see the linked
JSX POC for state lifetime, clipping, and error boundaries. The opt-in
[`jsx-file-view-gallery`](../examples/extensions/jsx-file-view-gallery/) runs
fixed JSX rows against checked-in TypeScript, CSS, and package dependency diffs. The focused
[`code-document-file-view`](../examples/extensions/code-document-file-view/) example declares
complete old/new documents and maps full and partial code slices beside non-syntax gutters.

A command handler can control the selected file's view through
`ctx.fileViews.select("view-id")`, `toggle("view-id")`, and
`isActive("view-id")`; pass `null` to `select` to restore raw rendering.
Bare ids address the calling extension; use `"other-extension:view-id"` to
address another registered view. The public command API remains current-file
only. When the current file already uses an alternate presentation, **View →
Apply “…” to all matching files** applies it to every file in the complete
changeset that passes that view's `matches` function, including files hidden by
the current filter. Nonmatches retain their existing choices, and host
constraints such as an active draft may temporarily keep a selected file raw.

`ctx.fileViews.refresh("view-id")` invalidates that view's prepared layouts.
Hunk treats `layout` as a pure derivation of `(file, width)` and reuses a
prepared result until one of those changes, so a view holding its own state — a
fold, a toggled overlay, a display mode — has nothing to change and would
otherwise keep painting its first answer. Flip the state, then ask for the
re-derivation:

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  let expanded = false;

  hunk.registerFileView({
    id: "outline",
    title: "Outline",
    matches: (file) => file.path.endsWith(".ts"),
    layout: ({ file }) => ({
      rows: (file.hunks ?? []).map((entry) => ({
        id: `hunk:${entry.index}`,
        spans: [{ text: expanded ? `Hunk ${entry.index + 1} · ${entry.header}` : entry.header }],
      })),
      hunkRows: (file.hunks ?? []).map((entry) => ({ startRow: entry.index, endRow: entry.index })),
    }),
  });

  hunk.registerCommand(
    { id: "toggle-detail", title: "Toggle outline detail", key: "f9" },
    (ctx) => {
      expanded = !expanded;
      ctx.fileViews.refresh("outline");
    },
  );
}
```

Refresh defaults to view-wide, not current-file: every file presenting the view
re-runs `matches` and `layout`, while files on raw diff or on another view do no
work. The rows already on screen stay there until their replacement resolves, so
a refresh never flashes back to raw diff mid-flight; a re-layout that declines,
throws, or times out falls back to raw exactly like any other failed layout.
Unknown ids warn and do nothing, and ids resolve the same way `select` resolves
them.

When the state that changed belongs to one file rather than the whole view — a
fold, a per-file edit buffer — scope the invalidation with `{ fileId }` so the
other files presenting the view keep their prepared rows:

```ts
const folded = new Map<string, boolean>();

hunk.registerCommand({ id: "fold", title: "Fold this file", key: "f10" }, (ctx) => {
  const fileId = ctx.selection.file?.id;
  if (!fileId) return;
  folded.set(fileId, !folded.get(fileId));
  ctx.fileViews.refresh("outline", { fileId });
});
```

That matters because **View → Apply “…” to all matching files** can leave one
view presenting every matching file in the changeset, and each of those files
would otherwise re-run third-party `layout` for a change only one of them made.
A `fileId` no reviewed file carries invalidates nothing and warns about nothing,
since ids can race a reload.

#### Interactive file views

Add a `mode` when a file view needs keyboard input. Hunk sends it keys after
focused inputs and dialogs, but before app commands.

```ts
let showPath = true;

hunk.registerFileView({
  id: "outline",
  title: "Outline",
  matches: () => true,
  layout: ({ file }) => ({
    rows: [{ id: "summary", spans: [{ text: showPath ? file.path : "Outline" }] }],
    hunkRows: [],
  }),
  mode: {
    onKey: (key, ctx) => {
      if (key.name !== "space") return "pass";
      showPath = !showPath;
      ctx.fileViews.refresh("outline");
      return "handled";
    },
  },
});

hunk.registerCommand({ id: "outline-keys", title: "Outline keys", key: "f9" }, (ctx) => {
  ctx.fileViews.enterMode("outline");
});
```

`enterMode(viewId)` selects the view and starts its mode, returning `false` if it
cannot. Only one file-view mode runs at a time. `exitMode()` stops it;
`isModeActive(viewId)` checks it.

`onKey` must return synchronously:

- `"handled"` consumes the key.
- `"pass"` continues through any active session keyboard mode, then Hunk's
  commands and focused scrolling.
- `"exit"` consumes the key and stops the mode.

When the file-view mode is the highest-priority input owner, Escape exits it and
never reaches `onKey`. Hunk also exits when the selected file, active presentation,
extensions, or review session changes. Optional `onEnter` and `onExit` lifecycle
callbacks must also return synchronously, and `onExit` runs exactly once per
activation. A failing or asynchronous `onEnter` or `onKey` exits the mode; any
callback failure warns without breaking the review.

### `hunk.registerLineHighlighter(highlighter)`

A line highlighter marks **character ranges inside Hunk's own diff rendering**
— syntax highlighting, word diff, and layout stay exactly as they are, and the
marked characters get a resolved background. It is the lever for search hits,
diagnostics mapped onto changed lines, secret scanning, coverage, and anything
else that wants to say “these exact characters, here.”
For a whole alternate presentation, use `registerFileView` instead.

```ts
import type { ExtensionLineHighlight, HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerLineHighlighter({
    id: "todos",
    highlight({ file }) {
      const marks: ExtensionLineHighlight[] = [];
      let newLine = 0;
      for (const raw of file.patch.split("\n")) {
        const header = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(raw);
        if (header) {
          newLine = Number(header[1]);
          continue;
        }
        if (newLine === 0 || raw.startsWith("-")) continue;
        const start = raw.slice(1).indexOf("TODO");
        if (start !== -1) {
          marks.push({ side: "new", line: newLine, range: [start, start + 4], tone: "warning" });
        }
        newLine += 1;
      }
      return marks;
    },
  });
}
```

Marks are addressed by **source coordinates**, never by rendered rows: `side`
(`"old"` or `"new"`), a 1-based `line` on that side, and a `[start, end)`
range in UTF-16 code units of the line's raw text — exactly what `indexOf` and
`RegExp.exec` return against `file.patch` lines or a `readDocument` result.
That addressing survives split vs unified layout, line wrapping, horizontal
scrolling, and collapsed-context expansion, and the extension never learns
Hunk's row model. A context line may be addressed through either side's line
number; split view mirrors the mark onto both halves of the row. Offsets that
land inside an emoji or a wide character widen outward to the whole glyph. A
mark paints terminal columns, so a range covering only characters that occupy
no column — bidi controls, zero-width spaces and joiners — paints nothing.

The `tone` says what a mark means — `"match"` (the default), `"current"` for
the one hit a search is standing on, `"info"`, `"warning"`, `"error"`, or
`"dim"`. Tones exist because a **color would be the extension's problem to get
right and it cannot be**: a fixed background that reads on a context line is
invisible on an added line's green. Hunk resolves each tinted tone against the
actual background of each marked line until it clears a minimum perceptual
distance — stronger than its own word-diff emphasis, backing off only before
the code on top would stop being readable — per theme, so a mark is never
invisible on a line kind or a theme. `"dim"` recedes the marked text toward the
line background (blending token foregrounds ~45% into the line background while
preserving token hues) with a guaranteed readability floor, ideal for
review-progress workflows (e.g. marked-as-reviewed hunks) or noise de-emphasis.
On a transparent cell there is no color to blend against, so resolution falls
back to the theme background and then to the appearance's own extreme: the mark
still paints, chosen against the surface Hunk assumes rather than the one behind
the terminal. `"current"` renders as reverse video (theme text as the block,
theme background as the glyphs), the convention `less` and vim use for the
active hit.

`highlight({ file, signal, readDocument })` may be sync or async, and returns
the complete set of marks for one file — or `null` for none. Hunk calls it per
reviewed file, bounded by the same timeout and concurrency discipline as file
views, and treats the result as a pure derivation of the file plus an
invalidation epoch: results are cached until `ctx.highlights.refresh` bumps the
epoch or the review reloads. A search that moves to the next match flips its
own state and refreshes rather than pushing marks into the host:

```ts
hunk.registerCommand({ id: "next", title: "Next match", key: "f9" }, (ctx) => {
  ctx.highlights.refresh("todos");
});
```

Refresh defaults to highlighter-wide; pass `{ fileId }` to re-derive one
file's marks and leave the rest untouched. Bare ids address the calling
extension's own highlighter, `"other-extension:highlighter-id"` addresses any
registered one, unknown ids warn and do nothing, and a `fileId` no reviewed
file carries invalidates nothing — ids can race a reload. The same controls
are available to session keyboard modes through their context, so a prompt's
submit can refresh marks directly.

Containment matches the rest of the system. Marks addressing lines the review
is not showing — inside a collapsed gap, absent from a partial patch — are
silently invisible rather than errors; expanding a gap reveals marks addressed
into it when the file's source is loaded. Structurally invalid entries are
dropped with one warning per file; a raw result longer than 10,000 entries, or
one larger than 2,000 ranges per file or 100 per line, is rejected whole rather
than truncated silently; a highlighter whose marks would push one file past
4,000 merged ranges across every highlighter is dropped for that file;
overlapping ranges resolve deterministically with the later range winning. A throwing,
rejecting, or timed-out `highlight` costs that file's marks from that
highlighter and nothing else. Because highlights are paint-only — they change
colors, never text or geometry — the failure mode is always “no marks,” never
a broken review. Highlights render in interactive sessions only; the static
pager fallback never runs extension code.

### Session keyboard modes

Register a session-wide mode when an extension needs to interpret review keys
without replacing a pane or exposing renderer internals. Registration is inert;
a command deliberately enters the mode through its own scoped controls:

```ts
let pending = "";

hunk.registerKeyboardMode({
  id: "normal",
  title: "Vim navigation",
  onEnter: () => {
    pending = "";
  },
  onExit: () => {
    pending = "";
  },
  onKey: (key, ctx) => {
    if (key.sequence === "g") {
      if (pending === "g") {
        pending = "";
        ctx.commands.execute("hunk.review.jumpToTop");
      } else {
        pending = "g";
      }
      return "handled";
    }

    pending = "";
    if (key.sequence !== "j") return "pass";
    ctx.commands.execute("hunk.review.stepDown");
    return "handled";
  },
});

hunk.registerCommand({ id: "vim", title: "Toggle Vim navigation", key: "ctrl+v" }, (ctx) => {
  if (ctx.keyboardModes.isActive("normal")) {
    ctx.keyboardModes.exitMode();
  } else {
    ctx.keyboardModes.enterMode("normal");
  }
});
```

`ctx.keyboardModes.enterMode(id)` resolves only a mode registered by the same
extension. `exitMode()` and `isActive(id?)` likewise act only on that extension's
active mode, so one extension cannot inspect or stop another. Entering a mode
replaces the previous session mode and runs its `onExit` first. While `onEnter` or
`onExit` runs, `enterMode()` and `exitMode()` return `false`; lifecycle callbacks
reset extension-owned state but cannot change keyboard ownership. Only one session
keyboard mode runs at a time.

`onKey` returns synchronously:

- `"handled"` consumes the key.
- `"pass"` continues through ordinary Hunk commands and focused scrolling.
- `"exit"` consumes the key and leaves the mode.

The context is intentionally small: `cwd`, `notify`, live public `commands`,
activation-scoped `keyboardModes`, `highlights` (so a mode can refresh line
marks directly), and `statusLine` (so a mode can show its live buffer or count
on the status row). A mode never needs a prompt: a prompt-shaped interaction is
a command plus `ctx.prompts.line()`, described under
[Status line](#status-line). Keys are frozen plain snapshots, not OpenTUI
events. Async/throwing callbacks are contained and exit safely. When the session
mode is the highest-priority active input owner, host-owned Escape exits without
reaching `onKey`; the status badge and a host-owned **Extensions** menu item are
clickable exits too. Controls handed to a mode are activation-scoped: after that
activation exits, retained callbacks cannot inspect, stop, or replace a later mode.
An active `onKey` may deliberately enter another mode from the same extension; its
outgoing lifecycle callback cannot supersede that replacement.

Dialogs, menus, focused filter/note inputs, and interactive file-view modes run
before a session mode. A file-view mode may temporarily overlap it: the first
Escape leaves the focused file-view mode, and the second leaves the resumed
session mode. Ordinary content soft reloads preserve a session mode, while an
extension reload, registry closure, or App teardown exits it exactly once.

Multi-key grammar and numeric prefixes belong to the extension. Resolve a count,
then call `ctx.commands.execute(id, { count })` once so the host applies movement
atomically. See the dependency-free
[`vim-navigation`](../examples/extensions/vim-navigation/) example for `j`/`k`,
`gg`/`G`, hunk movement, alignment, capped counts, Ctrl chords, and a focused
`:` command line composed from a registered command plus `ctx.prompts.line()`.

### `hunk.registerCommand(command, handler)`

Register a named command, optionally bound to a key. Commands share Hunk's
built-in dispatch table, with built-ins taking precedence.

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerCommand({ id: "hello", title: "Say hello", key: "ctrl+g" }, (ctx) => {
    ctx.notify("hello from a command");
  });
}
```

Key chords are `ctrl`, `alt`/`option`, `cmd`/`meta`, and `shift` joined with
`+` around a base key — a character (`"y"`, `"["`), an uppercase letter for its
shifted form (`"G"`), or a named key (`"f2"`, `"pageup"`, `"left"`). `shift`
applies to letters and named keys only: for a shifted symbol or digit, bind the
character the shift produces (`"!"`, not `"shift+1"`), since terminals report
the character rather than the combination. `ctrl+<letter>` also matches an
unnamed bare control byte; named Tab and Enter events stay distinct. An
unparsable chord fails registration. A chord already owned by a built-in or an
earlier extension stays with its owner and produces a warning. Omit `key` to
register a command with no binding.

`key` also takes a list, binding the command to every chord in it:

```ts
hunk.registerCommand({ id: "hello", title: "Say hello", key: ["ctrl+g", "f9"] }, (ctx) => {
  ctx.notify("hello from a command");
});
```

Chords are refused one at a time: if `ctrl+g` were already taken, the command
would still answer to `f9`.

Whatever an extension declares is a _default_. Users remap commands by id in the
`[keybindings]` table of their own config, extension commands included — yours
is named `"<extensionId>.<commandId>"`, while Hunk's own are `"hunk.app.quit"`
and friends. See [docs/keybindings.md](keybindings.md) for the rules; the
practical consequence is that a chord you declare may not be the chord your
command ends up on.

Every registered command is also listed in the menu bar's **Extensions** menu,
under its `title`, showing whichever key it currently answers to. The menu
appears only when something registered a command, entries are grouped by
extension in load order, and running one from the menu is the same dispatch the
key would have done — so a command with no `key`, or one whose chord was
refused, is still reachable with the mouse.

The handler fires when the key is pressed outside modal UI — dialogs, menus,
and focused text inputs own their keys first. It receives the standard context
plus `ctx.panes`, the controls for opening panes:

- `ctx.panes.open(paneId)` / `close(paneId)` / `toggle(paneId)` — a bare id
  names your own extension's pane, while a fully qualified
  `"<extensionId>:<paneId>"` key addresses any registered pane. Use
  `"hunk:files"` for the literal built-in pane. These controls address
  registrations directly; they do not resolve a replacement slot. To toggle
  whichever pane currently owns the files role,
  call `ctx.commands.execute("hunk.view.toggleFilesPane")`. Opening a left/right
  pane also reveals its own edge when responsive layout has hidden it, without
  revealing the opposite edge or changing other panes' logical open preferences.
  Top/bottom pane state is independent of the side edges.
- `ctx.panes.isOpen(paneId)` reports the logical open preference, including
  while availability or terminal bounds temporarily omit the pane. `toggle()`
  toggles this logical preference.
- `ctx.panes.isVisible(paneId)` reports whether the pane occupies the current
  committed layout, accounting for responsive hiding, availability, failures,
  and terminal bounds. Unknown panes and stale controls return `false`.
  To toggle rendered visibility without touching other panes:

  ```ts
  if (ctx.panes.isVisible("guide")) ctx.panes.close("guide");
  else ctx.panes.open("guide");
  ```

  Opening still respects availability and minimum review dimensions; it cannot
  force a pane to render when there is insufficient room.

`ctx.selection` is where the review was pointing when the command fired — the
same selection a pane component sees in its props, so a command never has to
track `selection_changed` itself to know what the user is looking at:

```ts
hunk.registerCommand(
  { id: "show-selection", title: "Show the selected file", key: "ctrl+y" },
  (ctx) => {
    const { file, hunkIndex } = ctx.selection;
    if (!file) {
      ctx.notify("No file selected");
      return;
    }

    ctx.notify(hunkIndex === null ? file.path : `${file.path} — hunk ${hunkIndex + 1}`);
  },
);
```

`selection.file` is a frozen read-only view, identical to the entries in a
pane's `files` prop. Extensions only receive visible files, so it is `null`
when filtering hides the selected file or when no files are visible.
`selection.hunkIndex` is that file's selected hunk, and `null` whenever `file`
is — or when the file has no hunks to select. `selection.currentLine` is the
one-based `{ side, line }` source address carrying the current-line marker, or
`null` when the marker is off or the review has not settled on a rendered line.
It belongs to this file and hunk, uses Hunk's canonical new-side address for a
context row, and can be passed directly to `navigation.revealLine`.
`selection.files` is every visible file in review order — the same frozen
views a pane's `files` prop carries — so a command that works across the whole
review (a content search, a bulk action) reads its corpus here instead of
shadow-tracking `changeset_loaded`; `selection.file` is one of its entries or
`null`. The values are captured when the command fires: a handler that awaits
still sees the selection it was run from, not wherever the user navigated to
meanwhile.

`ctx.commands` invokes Hunk's documented semantic commands through the exact same live command
table used by the keyboard, menus, and help:

```ts
hunk.registerCommand({ id: "skip-three", title: "Skip three hunks", key: "ctrl+j" }, (ctx) => {
  if (ctx.commands.isEnabled("hunk.review.nextHunk")) {
    ctx.commands.execute("hunk.review.nextHunk", { count: 3 });
  }
});
```

Only explicitly public built-in `hunk.*` commands can be invoked. Unknown, disabled,
extension-owned, or stale-session commands return `false`; an extension cannot recursively invoke
itself or another extension. `isEnabled` also returns `false` for malformed ids, while malformed
`execute` ids, options, and counts throw as extension programming errors. The public ids are the built-ins listed in
[keybindings](keybindings.md), including the unbound
`hunk.review.alignCurrentLineTop`, `hunk.review.alignCurrentLineCenter`, and
`hunk.review.alignCurrentLineBottom` commands.

`count` defaults to `1` and must be a positive safe integer no greater than `10,000`. Relative
line, viewport, horizontal, file, hunk, and annotated navigation applies the count atomically in
one host transition. Absolute positioning and one-shot commands run once. This avoids stale React
state without exposing scroll boxes, renderer refs, or coordinates. Both methods read live command
state, so they remain valid after an `await` or an ordinary content soft reload that retains the
extension registry. Controls retained across an extension-registry reload or App remount return
`false`.

`ctx.keyboardModes` enters, exits, or probes the command's own registered
session keyboard modes. See [Session keyboard modes](#session-keyboard-modes).

`ctx.highlights` refreshes prepared line-highlight marks, whole or
`{ fileId }`-scoped. See
[`hunk.registerLineHighlighter`](#hunkregisterlinehighlighterhighlighter).

#### Reading the authoritative review

`ctx.review.snapshot()` returns a deeply immutable projection of the shared
ReviewStore, or `null` after this command's review generation has been retired. The same
`ctx.review` object can set or clear a transient presentation scope for the current generation;
the host intersects scopes from active extensions and applies the result after the user's file
filter without changing canonical review state. Each scope names files by `fileId` and the
zero-based `hunkIndexes` to retain:

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerCommand({ id: "focus-first", title: "Focus first hunk" }, (ctx) => {
    const review = ctx.review.snapshot();
    const file = ctx.selection.files[0];
    const targetHunk = file?.hunks?.[0];
    if (!review || !file || !targetHunk) return;

    ctx.review.setPresentationScope({
      generation: review.generation,
      files: [{ fileId: file.id, hunkIndexes: [targetHunk.index] }],
    });
  });
}
```

The scope is rejected when its generation, file ids, or hunk indexes are stale. Clear it with
`ctx.review.clearPresentationScope()`; reloads, extension retirement, and failed commands clear
owned scopes automatically. It contains the opaque producer `generation`, the store's `stateRevision`, every
file in authoritative review/sidebar order, and every saved live or reviewer
note. Files carry their stable `fileKey`, transient `runtimeId`, content identity,
paths, stats, and flags; notes carry their complete resolved old/new anchor,
optional direct `parentId`, and `active`/`stale`/`orphaned` reconciliation status.

This is the command-time source for exporters, publishers, and audit tools. Drafts
are excluded because they are not saved. Static sidecar annotations that never
entered ReviewStore remain available on the changeset's file views, not in this
snapshot. Saved notes appear in live-arrival order followed by reviewer-creation
order, including orphaned notes a publisher may need to move into a summary.

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerCommand({ id: "publish", title: "Publish review" }, async (ctx) => {
    const captured = ctx.review.snapshot();
    if (!captured) return;

    await Promise.resolve(); // Prepare an external request from `captured` here.
    const current = ctx.review.snapshot();
    if (
      !current ||
      current.generation !== captured.generation ||
      current.stateRevision !== captured.stateRevision
    ) {
      ctx.notify("Review changed; rebuild the request", "warning");
      return;
    }
  });
}
```

Call `snapshot()` again before irreversible asynchronous work and compare both
fields: revisions are comparable only within one generation. Use `fileKey` for
semantic addressing, `contentIdentity` to detect changed reviewed content, and
`runtimeId` only for navigation inside that exact generation. The
[`review-snapshot-export`](../examples/extensions/review-snapshot-export/)
example writes the complete value as JSON and demonstrates this stale-work check. The
[`review-note-navigator`](../examples/extensions/review-note-navigator/) example composes
the complete note inventory, authoritative anchors, a selector dialog, and guarded navigation
to currently visible files.

#### Navigating the review

`ctx.navigation` moves the review stream: `selectFile(fileId)`,
`selectHunk(fileId, hunkIndex)`, and `revealLine(fileId, side, line)`, the same
guarded navigation a pane's `actions` carry, routed through the same review
controller — the stream scrolls, selection updates, and `selection_changed`
fires exactly as if the user had clicked a pane row. Unlike `selection` it is
live, not a snapshot: a call acts on the review as it is at that moment, so a
handler that awaits a dialog and then navigates still works. A file id the
stream cannot currently show is refused with a warning rather than corrupting
the selection, and a hunk index is clamped into the file's real range.

`revealLine` is the finest target there is, and the one to reach for when your
extension knows exactly which line it means — a search hit, a lint finding, the
line a mark from [`registerLineHighlighter`](#hunkregisterlinehighlighterhighlighter)
sits on. A hunk hundreds of lines tall has one anchor, so `selectHunk` can leave
the line you meant pages below the viewport; `revealLine` scrolls to the line
itself, lands it a little below the viewport top like every other Hunk reveal,
and makes it the current line so the reverse-video marker sits on it.

`line` is 1-based on `side` as the patch numbers it, so a context line answers
to either side's number. Two things soften the target rather than failing it:
when no rendered row carries that line — it is inside a collapsed gap, absent
from a partial patch, or the reviewer turned the current-line marker off
(`view.cursor_line = "off"`) — the jump lands on the hunk containing the line
instead. Only a line no hunk of the file covers is refused, with a warning
naming your extension, and so are a side outside `"old"`/`"new"` and a line
number that is not a positive whole number.

```ts
hunk.registerCommand({ id: "first-todo", title: "Jump to the first TODO" }, async (ctx) => {
  const file = ctx.selection.file;
  if (!file) {
    return;
  }

  const document = await ctx.workspace.readDocument(file.id, "new");
  const index = (document ?? "").split("\n").findIndex((line) => line.includes("TODO"));
  if (index >= 0) {
    ctx.navigation.revealLine(file.id, "new", index + 1);
  }
});
```

A handler may be async; a failure (sync or rejected) becomes a warning naming
your extension.

#### Asking the user

`ctx.dialogs` puts a question on screen and waits for the answer. Three shapes,
all promise-returning:

- `confirm({ title, body?, confirmLabel?, cancelLabel? })` → `true` or `false`
- `select({ title, options })` → the chosen string, or `null`
- `input({ title, placeholder?, initial? })` → the typed string, or `null`

```ts
hunk.registerCommand(
  { id: "reformat", title: "Reformat the selected file", key: "ctrl+r" },
  async (ctx) => {
    const file = ctx.selection.file;
    if (!file) {
      return;
    }

    const proceed = await ctx.dialogs.confirm({
      title: `Reformat ${file.path}?`,
      body: "The file is rewritten in place.",
      confirmLabel: "reformat",
    });

    ctx.notify(proceed ? `Reformatting ${file.path}` : "Left it alone");
  },
);
```

`select` is the natural fit for acting on part of the selection — here, asking
which hunk of the selected file to jump to, then navigating there:

```ts
hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, async (ctx) => {
  const file = ctx.selection.file;
  const hunks = file?.hunks ?? [];
  if (!file || hunks.length === 0) {
    ctx.notify("Nothing to pick from", "warning");
    return;
  }

  const labels = hunks.map((hunk) => hunk.header || `hunk ${hunk.index + 1}`);
  const picked = await ctx.dialogs.select({ title: "Which hunk?", options: labels });

  // `navigation` is live, so the jump is valid even after awaiting the dialog.
  if (picked !== null) {
    ctx.navigation.selectHunk(file.id, labels.indexOf(picked));
  }
});
```

Hunk draws the dialog, not you: your text fills the title, body, and choices,
and dialogs from installed extensions carry an `ext <your-id>` attribution line
— the same marker `notify` toasts use — so a third-party prompt can never present
itself as Hunk asking. Hunk's own bundled extensions omit that redundant marker.

One dialog is on screen at a time. Concurrent requests queue in call order,
across extensions too, so a second question waits its turn instead of replacing
the first. While a dialog is up it owns the keyboard: Escape cancels (`false`,
or `null`), Enter accepts — the confirm action, the highlighted option, or the
typed text — and review shortcuts stay suppressed underneath. Confirm dialogs
also answer to `y`/`n`, select dialogs to `↑`/`↓`, and every dialog's actions
and rows are clickable.

Two things resolve a dialog without the user: the session moving on, and bad
arguments. A session reload — the refresh key, a watch-triggered reload, an
agent command — cancels open and queued dialogs, since the review they asked
about is being replaced; a dialog pending at shutdown resolves its cancel value
the same way, and a request made after that point cancels immediately. A blank
`title`, or a `select` with no options, is a bug in the extension rather than an
answer from the user, so the promise **rejects**; like any other handler
failure, that surfaces as a warning naming your extension.

#### Workspace documents

`ctx.workspace` reads full documents from the current review and can replace an
eligible working-tree file.

| Method                                 | Result                                            |
| -------------------------------------- | ------------------------------------------------- |
| `readDocument(fileId, "old" \| "new")` | The reviewed source text, or `null`               |
| `canWriteDocument(fileId)`             | Whether the review and file allow writes          |
| `writeDocument({ fileId, text })`      | `{ ok: true }` or `{ ok: false, reason, detail }` |

A command can read, transform, and write a selected file:

```ts
hunk.registerCommand({ id: "shout-headings", title: "Shout headings", key: "f7" }, async (ctx) => {
  const file = ctx.selection.file;
  if (!file || !ctx.workspace.canWriteDocument(file.id)) return;

  const current = await ctx.workspace.readDocument(file.id, "new");
  if (current === null) return;

  const result = await ctx.workspace.writeDocument({
    fileId: file.id,
    text: current.replace(/^(#+ .+)$/gm, (heading) => heading.toUpperCase()),
  });

  if (!result.ok && result.reason !== "cancelled") {
    ctx.notify(result.detail, "warning");
  }
});
```

`readDocument` returns the exact source represented by the review, not the
file's patch. It works for every review kind. For example, the `"new"` side in
`hunk show HEAD` is the file at that commit, not the working-tree file. It
returns `null` when the file or side is absent, no source is available, reading
fails, or the document exceeds Hunk's size limit. Reads never prompt. An invalid
side rejects the promise.

Writes require all of the following:

- an unstaged working-tree review (`hunk diff` with no revision range)
- a reloadable session; `--agent-context -` sessions cannot write
- a reviewed file with writable new-side text
- a regular target inside the review root

Revision, stash, range, staged, patch, and file-pair reviews are read-only.
Deleted, binary, oversized, missing, symlinked, and root-escaping targets are
also refused. Targets are identified by reviewed file id, never by an arbitrary
path.

`canWriteDocument` checks the review and file policy without prompting or
inspecting the filesystem. A later `writeDocument` can still refuse if the file
has moved or become unsafe.

`writeDocument` verifies the target, asks for consent through the attributed
`ctx.dialogs` queue, then verifies it again before writing. The second check
prevents deletion and symlink-swap races while the dialog is open. Authority is
checked immediately before the filesystem call; once that irreversible write
starts, its actual success or failure wins even if another reload happens, and
graceful shutdown waits for it to settle. A successful write queues
reconciliation of the review then active, and the write promise may settle
before that reload finishes.

A declined prompt returns `cancelled`, an ineligible or unsafe target returns
`unavailable`, and an attempted write failure returns `failed` with a
displayable `detail`. Malformed requests reject the promise.

### `hunk.transformChangeset(fn)`

Rewrite the loaded changeset before it reaches the review UI. Transforms run in
registration order, each seeing the previous one's output, on first load and on
every reload.

```ts
hunk.transformChangeset((changeset) => ({
  ...changeset,
  files: changeset.files.filter((file) => !file.path.endsWith(".lock")),
}));
```

The function may be async. Filtering and reordering `files` is fully supported —
the panes and review stream both follow whatever you return.

Each file carries an opaque `metadata` field: it is the parsed diff the renderer
draws from, so pass it through untouched (spreading a file preserves it). What
you return is validated before it is reviewed. A transform that throws, or
returns something the review UI could not draw — not a changeset with a `files`
array, a file missing `metadata.hunks` or `stats`, two files sharing an `id` —
is skipped: the previous changeset carries forward and you get a warning naming
your extension and the problem.

You never need to reach into `metadata` to know what a file's hunks are: the
read-only views Hunk hands outward (event payloads, pane props, a command's
selection) carry a `hunks` list of public summaries — `index`, the `@@` header,
and the inclusive old/new line spans, in render order. Like `changeType`, it is
derived from the metadata at that boundary, so a transform neither receives nor
produces it, and a stale value spread through a transform is replaced with what
the metadata actually parses to.

### `hunk.on(event, handler)`

Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks
the UI waiting for one. Alongside `cwd` and `notify`, every handler receives
`ctx.panes`, live `ctx.navigation`, attributed `ctx.dialogs`, and review reload
controls. `ctx.sidebars` is a deprecated alias for `ctx.panes`. That means a `startup` handler can present
one focused welcome question and navigate to its first example, while a
`changeset_loaded` handler can reveal a pane when it finds something worth
showing — no keypress required. Dialog calls made before the mounted app is
ready resolve to their cancel value with a warning rather than opening later.
Controls retained across a review or extension-registry replacement expire:
navigation and pane mutations warn and do nothing, dialogs resolve to their
normal cancel value, and workspace reads or not-yet-started writes return
`null`/`unavailable` instead of acting on replacement content. Once a consented
filesystem write starts, it reports its actual outcome and success reconciles
the review then active.

| Event                  | Payload                                              | When                                                      |
| ---------------------- | ---------------------------------------------------- | --------------------------------------------------------- |
| `startup`              | `{ cwd }`                                            | once per loaded instance, after its review UI mounts      |
| `changeset_loaded`     | `{ changeset }`                                      | first load and every reload                               |
| `command_executed`     | `{ commandId, canonicalCommandId? }`                 | after a named command dispatches in this terminal host    |
| `selection_changed`    | `{ fileId, hunkIndex }`                              | when the review selection settles (debounced ~150ms)      |
| `file_viewed`          | `{ file, hunkIndex }`                                | when selection settles on a file or a reload replaces it  |
| `hunk_viewed`          | `{ file, hunkIndex }`                                | when selection settles on a different hunk                |
| `filter_changed`       | `{ filter }`                                         | whenever the file-filter query changes                    |
| `theme_changed`        | `{ themeId }`                                        | when the user commits a new theme                         |
| `layout_changed`       | `{ mode, layout, canonicalMode?, canonicalLayout? }` | mode or responsive split/unified layout changes           |
| `watch_reload_pending` | `{}`                                                 | watcher observed a change before its reload check         |
| `note_created`         | `{ note }`                                           | a user saves an inline review note                        |
| `note_edited`          | `{ note }`                                           | a draft body changes or an existing note is saved         |
| `note_changed`         | `{ kind, note }`                                     | a saved ReviewStore note is created, updated, or removed  |
| `session_reload`       | `{ changeset, reason }`                              | on every session reload                                   |
| `shutdown`             | `{}`                                                 | before instance replacement or exit, with a short timeout |

A newly mounted extension instance receives `startup` before its first
`changeset_loaded`; reloads then deliver `changeset_loaded` before
`session_reload` once the matching review generation has committed.

Starting with extension API v23, `layout_changed` adds `canonicalMode` and
`canonicalLayout`. They emit `"auto"`, `"split"`, or `"unified"` for the mode and
`"split"` or `"unified"` for its resolved layout. The original `mode` and `layout`
fields remain available for compatibility and continue to report `"stack"`
where their canonical counterparts report `"unified"`. To preserve exhaustive
existing source, `ExtensionLayoutMode` and `ExtensionResolvedLayout` retain their
pre-v23 shapes; new extensions use `ExtensionCanonicalLayoutMode` and
`ExtensionCanonicalResolvedLayout`. Hunk will keep the deprecated `"stack"`
literal and legacy event fields until a separately announced major API revision.

`selection_changed` is trailing-debounced on purpose: holding `[`/`]` retargets
the selection many times a second, and handlers only care where the user landed.
`fileId` and `hunkIndex` are `null` when nothing is selected.

`hunk_viewed` is the hunk-grain sibling of `file_viewed`. It fires when the
settled `(file, hunk)` pair changes — including `[`/`]` inside one file — and
does not fire for current-line movement within a hunk. `file_viewed` still fires
only when the selected file object changes, so a soft reload can report a fresh
file without counting as a new hunk read.

`command_executed` reports a stable command id after the terminal dispatcher invokes
it, whether the user reached it through a key, a menu, an old command alias, or
`ctx.commands.execute`. When a command was renamed, `commandId` preserves its deprecated
identity for existing handlers and `canonicalCommandId` names the replacement. Extension commands
may still have detached async work in flight; this event observes the accepted user action, not
promise settlement. Listen for ids rather than key chords so behavior follows the user's live
`[keybindings]` table. Browser/session actions lower to shared review intents rather than terminal
commands and do not emit this event. Modal widget keys such as Escape, Enter,
and F10 menu navigation are also not commands. The note composer's save shortcut
is `hunk.review.saveNote` and does emit this event.

`session_reload`'s `reason` is `"watch"` (the watcher saw the source change),
`"daemon"` (an agent command through the session broker), `"extension"` (an
in-process extension request), or `"manual"` (the refresh key, or the reload
after granting extension trust).

`note_created` and `note_edited` cover notes authored in Hunk's own UI, in this
session. `note_edited` carries `note.draft: true` for composer changes and
`note.draft: false` for an identity-preserving saved-note edit. Replies include their direct
`parentId`. Optional `note.oldRange` and `note.newRange` values are inclusive, one-based source
ranges: line notes may carry singleton ranges, while replacement selections may carry both.
`note.side` and `note.line` identify the preferred endpoint used to place the note. Review notes
are session-local state, so there is no backlog to replay
on startup — but comments added through agent session commands do not emit
these events, and a `session_reload` may remap or drop notes without one
either. Use them for incremental UI reactions only.

`note_changed` is the store-backed path: `kind` is `"created"`, `"updated"`, or
`"removed"`, and `note` is the same snapshot shape `ctx.review.snapshot()`
returns. It fires for user saves and deletes and for agent session comments.
Drafts never appear. A TUI save therefore emits both `note_created` (or
`note_edited`) and `note_changed`. Reloads that remap or drop notes do not emit
`note_changed`; listen for `session_reload` to invalidate extension-owned state,
then read `ctx.review.snapshot()` from a later command when it needs the complete
current saved-note record.

`shutdown` handlers get a short window (250ms) to finish before Hunk replaces
the extension registry or exits anyway, so make cleanup prompt and idempotent.
Host-mediated UI authority is already revoked when shutdown begins: use the
event to release extension-owned resources, not to navigate or open dialogs.
The replacement instance receives `startup` after its review is mounted.

### `hunk.events`

`hunk.events` is a small bus shared by every loaded extension. Use it to
coordinate extensions without coupling them through a command or global state.
Names are open-ended, so namespace them with your extension id. Listeners get
the same `ctx.panes`, `ctx.navigation`, `ctx.dialogs`, and `ctx.review` reload
controls as lifecycle handlers; `ctx.sidebars` remains a deprecated pane alias. Delivery is
fire-and-forget and one listener's failure is reported without stopping the
others. Events an
extension emits while factories are loading are queued until every extension
has had a chance to subscribe.

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.events.on<{ fileCount: number }>("summary:ready", (payload, ctx) => {
    if (payload.fileCount > 100) ctx.panes.open("summary");
  });

  hunk.on("changeset_loaded", ({ changeset }, ctx) => {
    hunk.events.emit("summary:ready", { fileCount: changeset.files.length });
    ctx.panes.open("summary");
  });
}
```

Bus payloads are shallow-frozen copies when they are objects. Keep nested data
immutable if multiple extensions will read it.

### `ctx.review.requestReload()` in event handlers

Request a soft reload of the currently mounted review after an external agent,
service, or process changes its inputs. This does not require `--watch`. Hunk
reuses the current input and live view options, preserves mounted UI state and
selection where possible, serializes the work with every other reload, and
coalesces concurrent extension requests into one operation.

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.events.on("agent:files-changed", async (_payload, ctx) => {
    const result = await ctx.review.requestReload();
    if (!result.ok) ctx.notify(result.detail, "warning");
  });
}
```

Success resolves `{ ok: true }` after the replacement review commits and emits
`session_reload` with reason `"extension"`. Non-reloadable inputs and controls
retained from an expired review resolve `unavailable`; a reload that starts but
cannot complete resolves `failed` with a displayable `detail`. Factory-time bus
events can run before the app mounts, so their reload requests are unavailable.
Once a live request starts, it reports that operation's actual result even
though a successful reload expires the context that requested it.
Requests made from the successor's lifecycle handlers are a new generation and
can schedule a trailing reload. Check `session_reload.reason` before requesting
from that event so an extension does not create its own reload loop.

### `hunk.config`

Your extension's own `[extension.<id>]` config table, as a plain object. Hunk
does not interpret the keys — unknown keys pass straight through — and repo
config overrides user config key by key.

> **Treat these values as untrusted.** Tables merge by extension id with no
> notion of where the extension was installed from, so a repository under review
> can set or override configuration for an extension you installed globally.
> That is deliberate — repo-level tuning of a shared extension is a normal team
> workflow, and Hunk shows a startup notice listing the extension ids a repo
> configures — but it means `hunk.config` must never be trusted for
> exec-adjacent decisions such as binary paths, shell commands, or module
> loading. Validate those against something the user controls.

```toml
# ~/.config/hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "dist/**"]
```

```ts
const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"];
```

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

Every handler and transform receives a context object with `cwd` and `notify`.
Event and bus handlers additionally receive `panes`, `review.requestReload`, and `events.emit`; command
handlers receive `panes`, `selection`, `navigation`, and `dialogs`. The deprecated
`sidebars` alias remains available during the API-v4 compatibility window. `notify`
shows a single unobtrusive line at the bottom of the app that clears
itself after a few seconds; queued messages appear in turn. `type` is `"info"`
(default), `"warning"`, or `"error"`, which selects the color. Notifications
raised before the UI has mounted are buffered and flushed once it does, so a
`startup` handler can notify safely.

### `hunk.log(message)`

Record a diagnostic line. Logs are collected per extension rather than written
to the terminal, because the TUI owns the screen.

## A complete example

Installable extensions and examples include:

- [`pane-layout`](../examples/extensions/pane-layout/) for all four placements.
- [Hunk Lens](https://github.com/modem-dev/hunk-lens) for opaque selected-row
  paint (`hunk extension install modem-dev/hunk-lens`).
- [`review-triage`](../examples/extensions/review-triage/) for panes, commands,
  dialogs, lifecycle events, and the event bus.
- [`review-snapshot-export`](../examples/extensions/review-snapshot-export/) for
  authoritative saved-note export and generation/revision stale-work checks.
- [`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/)
  parses Markdown into generic host-owned file-view rows. Its README shows how
  to run it from the checkout or copy it into the global extensions directory.
- [`examples/extensions/code-document-file-view/`](../examples/extensions/code-document-file-view/)
  maps complete old/new code documents and partial UTF-16 slices into symbolic rows while Hunk owns
  syntax colors and fallback.

Collapse lockfiles and generated output out of every review, and say how many
files were hidden.

```ts
// ~/.config/hunk/extensions/collapse-generated.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

/** Match one path against a `*`-only glob, anchored at both ends. */
function matchesPattern(path: string, pattern: string) {
  const source = pattern
    .split("*")
    .map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"))
    .join(".*");
  return new RegExp(`^${source}$`).test(path);
}

export default function (hunk: HunkExtensionAPI) {
  const patterns = (hunk.config.patterns as string[] | undefined) ?? [
    "*.lock",
    "*-lock.json",
    "dist/*",
  ];

  hunk.transformChangeset((changeset, ctx) => {
    const kept = changeset.files.filter(
      (file) => !patterns.some((pattern) => matchesPattern(file.path, pattern)),
    );

    const hidden = changeset.files.length - kept.length;
    if (hidden > 0) {
      ctx.notify(`Collapsed ${hidden} generated ${hidden === 1 ? "file" : "files"}`);
    }

    return { ...changeset, files: kept };
  });
}
```

Configure it without touching the code:

```toml
# .hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "bun.lockb", "generated/*"]
```

Try it against the working tree without installing it:

```bash
hunk diff --extension ./collapse-generated.ts
```

## CLI flags and config reference

```bash
hunk diff --extension ./path/to/entry.ts   # load one entry file for a review (repeatable)
hunk diff --extension ./my-ext             # a folder extension: loads ./my-ext/index.ts
hunk --extension ./my-ext cli-tools status # load then run its top-level CLI command
hunk --no-extensions cli-tools status      # no discovery or import; command is unavailable
hunk diff --no-extensions                  # disable user extensions for this review
hunk --disable-extension hunk.gh gh pr 123 # disable one extension for this run
hunk --enable-extension hunk.gh gh pr 123  # override a configured disable for this run
```

```toml
# ~/.config/hunk/config.toml or .hunk/config.toml
[extensions]
enabled = true                      # false disables loading for this layer
paths = ["~/dev/hunk-ext/index.ts"] # extra entry files or directories
disabled = ["hunk.gh", "my-extension"] # exact selection ids

[extension.my-extension]            # opaque payload handed to that extension
some_key = "some value"
```

`[extensions] enabled` layers like every other option: a repo `.hunk/config.toml`
overrides your user config. `--no-extensions` is a hard off switch that no config
layer can re-enable. Both govern **user** extensions only — Hunk's bundled
Git, Jujutsu, and Sapling backends load either way. User and repo `disabled` lists combine as a
union, so a repository cannot re-enable a user-disabled extension; only an explicit
`--enable-extension <id>` can do that for one invocation. Disabled user candidates are filtered
before module import and before repo trust prompting. `[extensions] paths` from a repo config is
trust-gated the same way `.hunk/extensions` is, because it is repo-controlled either way.

## Not contributable yet

Menu entries, standalone keybindings (a chord contributed without a command —
commands registered through `registerCommand` **are** already user-remappable
via `[keybindings]`), custom note renderers, and session commands are not
contributable yet. Generic top-level CLI trees use `registerCliCommand`; TUI
commands and their default key bindings use `registerCommand`. See the
[extension architecture](extension-architecture.md) for the current host design. The
[original exploration](extension-system-exploration.md) records historical rationale and phasing.

---

### File View Jsx Poc (`docs/file-view-jsx-poc.md`)

# Fixed-height JSX file-view rows (POC)

This worktree experiments with one constrained escape hatch in the symbolic file-view contract. A validated row may include one atomic `component: { height, render }` descriptor. Hunk mounts `render` as a real React/OpenTUI component while keeping the normal review-stream layout declarative and host-owned. Like all Hunk extensions, this is a cooperative trusted-code contract rather than a sandbox.

## Contract

- Layout still happens before paint and supplies stable row IDs plus one inclusive row range for every parsed hunk.
- Every custom row must retain symbolic `spans`. They are rendered if the component fails, clipped to the same declared fixed height as the component painter. Symbolic-only layouts continue through the existing renderer unchanged.
- A component row declares height and renderer atomically as `component: { height, render }`. `render` must be a function, one row is limited to 256 terminal lines, and the measured height of all symbolic and component rows together is limited to 100,000 terminal lines. Existing row/span/text limits still apply. An invalid layout falls back to raw diff.
- Hunk passes only `width`, fixed `height`, `selected`, zero-based `rowIndex`, and the shared semantic `theme` palette used by extension sidebars. Theme is paint-only: switching it rerenders mounted painters without relayout or geometry changes. A per-row closure can capture arbitrary parsed or semantic data without adding an opaque payload to the host contract.
- Hunk mounts `component.render` inside a fixed `component.height`/`minHeight`/`maxHeight`, `flexShrink: 0`, overflow-hidden wrapper. No post-mount measurement feeds back into geometry. Stable IDs, hunk bounds, selection, scrolling, and row windowing remain host-owned.

## Deliberate limits

- **Hook state is ephemeral paint state.** It is retained while a row stays mounted across selected-hunk prop updates. A component outside the host row window is unmounted and loses that state. State also resets for a file-view switch, extension/session reload, width-driven relayout, or any replacement layout generation. Extensions that need a durable review action must use registered commands rather than component-local state.
- **Component resizing is ignored.** Content that asks for a different size is clipped to the declared row height. There is no resize callback or measurement pass.
- **Custom rows are non-focusable paint surfaces.** They do not own review keyboard input; registered commands and menus are the supported keyboard path and preserve mouse/keyboard parity. Pointer delivery is experimental and cooperative: when normal routing delivers an un-dragged left-button mouse-up, a row may act, call `preventDefault()`, and stop propagation. Wheel, drag, and every unhandled input remain host-owned for scrolling and copy selection. Hunk provides no focus, portal, renderer, or input-delivery guarantee to custom rows.
- **Inline notes require exact bindings.** Rows may expose non-overlapping `sourceRanges`; Hunk inserts note cards before the uniquely bound row using the same plan for measurement and rendering. If any visible note lacks a bound preferred-side anchor, the complete file temporarily falls back to raw diff rather than hiding or guessing at review data. Draft editing remains raw-only.
- **Error containment is row-local only.** Synchronous render/lifecycle errors caught by React's row boundary replace that component with its symbolic spans. Hunk emits one attributed warning per extension, view, file, row, layout generation, and error message through the extension notice path; a new generation may warn again. Event-handler, promise, timer, portal, renderer-global, and other asynchronous errors are outside React error-boundary containment.
- **Clipping is not a security boundary.** Host-served runtime modules may expose capabilities outside normal row composition, but file views make no promise that portals, renderer access, focus, or input hooks work. A malicious or careless extension can still affect its trusted process. The POC proves geometry preservation for cooperative components, not enforceable containment of arbitrary code.
- Cooperative custom rows cannot replace the file section, control outer flex layout, request post-mount geometry changes, or bypass the host's raw fallback and resource validation through normal descendant rendering.

See `examples/extensions/jsx-file-view/` for the smallest opt-in hook-using example. The checked-in `examples/extensions/jsx-file-view-gallery/` adds three real-diff demos: a responsive TypeScript change atlas, exact-source CSS color swatches, and semantic `package.json` version highlights.

---

### Keybindings (`docs/keybindings.md`)

# Keybindings

Every keyboard shortcut is a named command, and the `[keybindings]` table maps
command ids to the keys you want them on:

```toml
[keybindings]
"hunk.app.quit" = "ctrl+x"               # one chord
"hunk.review.nextHunk" = ["]", "ctrl+n"] # several chords for one command
"hunk.review.focusFilter" = "/"          # takes "/" back from content search
"hunk.view.toggleMenuBar" = false        # unbind it entirely
"myext.toggle" = "ctrl+g"                # extension commands too
```

Every id starts with the name of whoever owns the command: Hunk's own commands
live under `hunk.`, and an extension's live under its extension id. That split
is structural — `hunk` is a reserved extension id, so an extension can never
mint a command id that shadows a built-in, whatever Hunk adds later. Extension commands currently
run on review surfaces; history resolves its built-in command set without claiming review-only extension chords.

Rules worth knowing:

- **User bindings replace defaults.** Listing chords for a command is the
  complete set of keys it answers to, not an addition to the shipped ones.
- **A key you bind is yours.** Any command that held the same chord only as a
  default gives it up, keeping its other keys. Above, `hunk.search.find` hands
  `/` to the filter while `n` / `N` still step search matches.
- **`false` (or `[]`) unbinds a command**, leaving its keys doing nothing.
- **Unbound commands are one line away.** The file filter ships without a key
  (Tab and the File menu reach it), as do `hunk.review.nextNote` /
  `previousNote` (`}` / `{` step through annotated hunks); one `[keybindings]`
  line gives any of them a chord.
- Two entries claiming one chord is a conflict: the first in the file wins and
  the session reports the other. Unknown command ids and unusable chords are
  reported the same way, and the rest of the table still applies.
- **Escape clears an active visual selection contextually** after overlays and
  extension modes have had their normal ownership. Without a selection, Escape
  remains available to extension commands because clear-selection is unbound.

Chords are `ctrl`, `alt`/`option`, `cmd`/`meta`, and `shift` joined with `+`
around a base key: a character (`"y"`, `"["`), an uppercase letter for its
shifted form (`"G"`), or a named key (`"tab"`, `"pageup"`, `"left"`, `"f2"`).
`shift` applies to letters and named keys only — for a shifted symbol or digit,
write the character the shift produces (`"!"`, not `"shift+1"`), since that is
what terminals report. `ctrl+<letter>` also matches an unnamed bare control
byte; named Tab and Enter events stay distinct. `alt`/`option` matches both
explicit Alt events and the Escape-prefixed form used by legacy terminals. A
legacy terminal cannot distinguish Alt from Meta, so `alt+n` and `meta+n` may
overlap there; Kitty keyboard events keep them distinct.

Inline saved notes also expose clickable **Edit**, **Reply**, and (for reply-free user notes)
**Delete** actions. `E` edits the first editable user note in the selected hunk and `R` replies
to its first visible stored note. Replies inherit the code anchor and may be nested without a
product depth limit. Static sidecar annotations are not reply targets, and a parent cannot be
deleted until its replies are removed.

The built-in commands and the keys they ship with:

On a terminal, `hunk log` opens its read-only history browser automatically. Its `hunk.history.*`
commands use the same configurable keybinding resolver as review while retaining history-specific effects.
Shared application commands such as `hunk.app.quit`, `hunk.app.toggleHelp`, and
`hunk.view.openThemeSelector` keep the same ids on both surfaces. `F10` opens File, View, Navigate, Commit, and Help menus;
View includes Hunk's shared theme selector and an optional **Graph view** that replaces the default
day-grouped timeline with commit-topology lanes. It uses `Up`/`Down` or `j`/`k` to move; press `v` first to
extend a contiguous commit selection with those same keys. `Shift+Up`/`Shift+Down` or uppercase `K`/`J` extend
directly, and `Escape` collapses the selection. `PageUp`/`PageDown`, `b`/`f`, or `Shift+Space`/`Space` page
through history; `u`/`d` or `Ctrl-U`/`Ctrl-D` move by half a page; `g`/`G` or `Home`/`End` jump,
`/` to search, `n`/`N` for matches, `t` to choose a theme, `r` to refresh, `y` to copy the focused commit's full id,
`Enter` to open the selection in normal Hunk review, and `q` or `Ctrl-C` to quit. With a mouse, Shift-click a row to extend the
selection when the terminal forwards modifiers, click a commit id to open it immediately, click the adjacent copy
icon to copy its full immutable id, click elsewhere on a row to select it, or double-click a row to open it.
Range selection is unavailable with `--all` or author, message, date, and path filters because those traversals
can interleave unrelated commits or hide intermediate commits. Quitting the opened review returns to the retained history selection and viewport. The Commit menu's
**Compare with first parent** and **Compare with parent…** actions compare the selected commit against
an ordered provider-owned parent; they do not navigate the history selection to that parent.

History-specific commands:

| Command id                          | Does                         | Default keys                 |
| ----------------------------------- | ---------------------------- | ---------------------------- |
| `hunk.history.openSelection`        | Open the selected commit(s)  | `enter`                      |
| `hunk.history.copyRevision`         | Copy the focused commit id   | `y`                          |
| `hunk.history.refresh`              | Refresh repository history   | `r`                          |
| `hunk.history.previousCommit`       | Move to the previous commit  | `up`, `k`                    |
| `hunk.history.nextCommit`           | Move to the next commit      | `down`, `j`                  |
| `hunk.history.startVisualSelection` | Start visual selection       | `v`                          |
| `hunk.history.clearSelection`       | Clear visual selection       | `escape`                     |
| `hunk.history.extendPrevious`       | Extend selection upward      | `shift+up`, `K`              |
| `hunk.history.extendNext`           | Extend selection downward    | `shift+down`, `J`            |
| `hunk.history.pageUp`               | Move up one page             | `pageup`, `b`, `shift+space` |
| `hunk.history.pageDown`             | Move down one page           | `pagedown`, `space`, `f`     |
| `hunk.history.halfPageUp`           | Move up half a page          | `u`, `ctrl+u`                |
| `hunk.history.halfPageDown`         | Move down half a page        | `d`, `ctrl+d`                |
| `hunk.history.jumpToFirst`          | Jump to the first commit     | `home`, `g`                  |
| `hunk.history.jumpToLast`           | Jump to the last commit      | `end`, `G`                   |
| `hunk.history.search`               | Search history               | `/`                          |
| `hunk.history.nextMatch`            | Select the next match        | `n`                          |
| `hunk.history.previousMatch`        | Select the previous match    | `N`                          |
| `hunk.history.toggleGraph`          | Toggle graph presentation    | _(none)_                     |
| `hunk.history.toggleUnicode`        | Toggle Unicode graph lines   | _(none)_                     |
| `hunk.history.toggleAuthor`         | Toggle author metadata       | _(none)_                     |
| `hunk.history.toggleDate`           | Toggle date metadata         | _(none)_                     |
| `hunk.history.toggleDecorations`    | Toggle ref decorations       | _(none)_                     |
| `hunk.history.openFirstParent`      | Compare with first parent    | _(none)_                     |
| `hunk.history.openParent`           | Choose a parent to compare   | _(none)_                     |
| `hunk.history.showAbout`            | Show application information | _(none)_                     |

Review and shared commands:

| Command id                                     | Does                                           | Default keys                 |
| ---------------------------------------------- | ---------------------------------------------- | ---------------------------- |
| `hunk.app.openAgentSkill`                      | Show agent skill                               | _(none)_                     |
| `hunk.app.quit`                                | Quit                                           | `q`                          |
| `hunk.app.refresh`                             | Refresh the review                             | `r`                          |
| `hunk.app.toggleFocusArea`                     | Switch focus between files and filter          | `tab`                        |
| `hunk.app.toggleHelp`                          | Toggle help                                    | `?`                          |
| `hunk.review.alignCurrentLineBottom`           | Align current line to viewport bottom          | _(none)_                     |
| `hunk.review.alignCurrentLineCenter`           | Center current line in viewport                | _(none)_                     |
| `hunk.review.alignCurrentLineTop`              | Align current line to viewport top             | _(none)_                     |
| `hunk.review.clearSelection`                   | Clear the active visual selection              | _(none)_                     |
| `hunk.review.copySelection`                    | Copy the active visual selection               | `y`                          |
| `hunk.review.deleteActiveNote`                 | Delete active review note                      | `D`                          |
| `hunk.review.editActiveNote`                   | Edit active review note                        | `E`                          |
| `hunk.review.editSelectedFile`                 | Open the selected file in your editor          | `e`                          |
| `hunk.review.focusFilter`                      | Focus the file filter                          | _(none)_                     |
| `hunk.review.halfPageDown`                     | Scroll down half a page                        | `d`, `ctrl+d`                |
| `hunk.review.halfPageUp`                       | Scroll up half a page                          | `u`, `ctrl+u`                |
| `hunk.review.jumpToBottom`                     | Jump to end                                    | `G`, `end`                   |
| `hunk.review.jumpToTop`                        | Jump to start                                  | `g`, `home`                  |
| `hunk.review.nextAnnotatedFile`                | Next annotated file                            | _(none)_                     |
| `hunk.review.nextAnnotatedHunk`                | Next annotated hunk                            | `}`                          |
| `hunk.review.nextFile`                         | Next file                                      | `.`                          |
| `hunk.review.nextHunk`                         | Next hunk                                      | `]`                          |
| `hunk.review.nextNote`                         | Next review note                               | _(none)_                     |
| `hunk.review.pageDown`                         | Scroll down one page                           | `pagedown`, `space`, `f`     |
| `hunk.review.pageUp`                           | Scroll up one page                             | `pageup`, `b`, `shift+space` |
| `hunk.review.previousAnnotatedFile`            | Previous annotated file                        | _(none)_                     |
| `hunk.review.previousAnnotatedHunk`            | Previous annotated hunk                        | `{`                          |
| `hunk.review.previousFile`                     | Previous file                                  | `,`                          |
| `hunk.review.previousHunk`                     | Previous hunk                                  | `[`                          |
| `hunk.review.previousNote`                     | Previous review note                           | _(none)_                     |
| `hunk.review.replyToActiveNote`                | Reply to active review note                    | `R`                          |
| `hunk.review.saveNote`                         | Save a draft review note                       | `ctrl+s`                     |
| `hunk.review.scrollCodeLeft`                   | Scroll code left (shifted scrolls fast)        | `left`, `shift+left`         |
| `hunk.review.scrollCodeRight`                  | Scroll code right (shifted scrolls fast)       | `right`, `shift+right`       |
| `hunk.review.startNote`                        | Add a review note                              | `c`                          |
| `hunk.review.startVisualSelection`             | Start visual line selection                    | `v`                          |
| `hunk.review.stepDown`                         | Move down one line or note                     | `down`, `j`                  |
| `hunk.review.stepUp`                           | Move up one line or note                       | `up`, `k`                    |
| `hunk.review.toggleHunkGap`                    | Expand or collapse the selected context        | `z`                          |
| `hunk.search.find`                             | Search diff content                            | `/`                          |
| `hunk.search.next`                             | Next search match                              | `n`                          |
| `hunk.search.previous`                         | Previous search match                          | `N`                          |
| `hunk.view.applyFilePresentationToAllMatching` | Apply current file presentation to all matches | _(none)_                     |
| `hunk.view.cursorLineNumber`                   | Mark the current line number                   | _(none)_                     |
| `hunk.view.cursorLineOff`                      | Hide the current-line marker                   | _(none)_                     |
| `hunk.view.cursorLineRow`                      | Highlight the current row                      | _(none)_                     |
| `hunk.view.layoutAuto`                         | Auto layout                                    | `0`                          |
| `hunk.view.layoutSplit`                        | Split layout                                   | `2`                          |
| `hunk.view.layoutUnified`                      | Unified layout                                 | `1`                          |
| `hunk.view.openThemeSelector`                  | Choose theme                                   | `t`                          |
| `hunk.view.toggleAgentNotes`                   | Toggle agent notes                             | `a`                          |
| `hunk.view.toggleCopyDecorations`              | Toggle copy decorations                        | _(none)_                     |
| `hunk.view.toggleFilesPane`                    | Toggle files pane                              | `s`                          |
| `hunk.view.toggleHunkHeaders`                  | Toggle hunk headers                            | `m`                          |
| `hunk.view.toggleLineNumbers`                  | Toggle line numbers                            | `l`                          |
| `hunk.view.toggleLineWrap`                     | Toggle line wrapping                           | `w`                          |
| `hunk.view.toggleMenuBar`                      | Toggle menu bar                                | `M`                          |

The files-pane command follows the named `hunk:files` role. If an extension
replaces that role, the command and **View → Files pane** toggle the resolved
replacement on any terminal edge without changing unrelated panes. Remapping or
unbinding `hunk.view.toggleFilesPane` changes that role-aware action, not an
extension pane's own commands. The former `hunk.view.toggleSidebar` id remains a
compatibility alias; prefer the files-pane name in new config and extension
code. Likewise, `hunk.view.layoutStack` remains a deprecated alias for
`hunk.view.layoutUnified`, so existing keybindings and extension command calls
continue to select the canonical unified layout.

Commands marked _(none)_ ship without a key: they remain callable by command id
and can be assigned a shortcut through `[keybindings]`. Some also appear in a
menu, while semantic commands such as current-line alignment do not need a menu
entry.

The menus and the controls help dialog (`?`) show the keys for the commands they
present, so remapping something changes what they advertise. Unbinding a menu
command keeps its menu item and simply stops showing a key.

Extension commands are named `<extensionId>.<commandId>` and remap the same way
(see [docs/extensions.md](extensions.md)). An explicitly activated extension
keyboard mode is a routing layer rather than a second command table: it may
consume a key, pass it to these resolved bindings, or consume it and exit. Its
multi-key grammar and counts are extension-owned, but resolved actions should
invoke these same public `hunk.*` commands.

Routing precedence is host prompts and dialogs, menus/overlays, focused text
inputs, an interactive file-view mode, a session extension keyboard mode, then
the command table and focused review widget. Keys that belong to a dialog,
menu, or focused text input — `Esc`, `Enter` — are part of those widgets rather
than commands, and are not remappable. The note composer's save shortcut is the
command `hunk.review.saveNote` (default `ctrl+s`) and is remappable; while the
composer is focused it still wins over the command table, using the resolved
chord. Escape is also the reserved exit from each active extension mode, so an
extension cannot trap the keyboard.

```toml
[keybindings]
"hunk.review.saveNote" = "ctrl+enter"   # Zellij-friendly; default is ctrl+s
```

`[keybindings]` is read from your user config only — never from a repository's
`.hunk/config.toml`. Which keys do what is a property of your keyboard and your
habits, so a checkout you review cannot rearrange them.

---

### Module Boundaries (`docs/module-boundaries.md`)

# Module boundaries

Defines Hunk's current import boundaries and preserves the migration record that established them. The boundaries are enforced by
[dependency-cruiser](https://github.com/sverweij/dependency-cruiser) over the production import
graph (`packages/hunk/src/` plus `packages/`, tests excluded):

- `bun run deps:check` — fails CI on any boundary violation not in the baseline.
- `bun run deps:baseline` — regenerates `.dependency-cruiser-known-violations.json` after fixing
  a violation. The baseline is **shrink-only**: entries leave when the underlying edge is fixed,
  and nothing is ever added. New code must respect the boundaries from day one.
- Rules live in `.dependency-cruiser.cjs`; each rule's comment states the boundary it protects.

`scripts/quality/source-boundaries.test.ts` remains the deeper, hand-authored gate for the review seam
(browser-safe closure, Node-debt tombstones). The dependency-cruiser rules are the coarse
tier-level complement, with real module resolution instead of regex import scanning.

## Current architecture

The product source follows these tiers, bottom to top. A tier imports only lower tiers unless an
explicit rule below names a facade or adapter exception.

```text
packages/hunk/src/extension-api   published contract; imports nothing
packages/hunk-vcs/                private dependency-bottom VCS helpers; explicit leaf exports only
packages/hunk/src/lib             small compatibility helpers
packages/hunk/src/core            product model, non-rendering runtime primitives, and policy
packages/hunk/src/extensions      extension host and bundled registration adapters
packages/hunk/src/session         daemon/broker transport and protocol
packages/hunk/src/app             CLI/startup/session composition; no rendering
packages/hunk/src/ui              terminal surface and named app/session adapters
packages/hunk/src/opentui         published facade re-exporting selected ui/core pieces
packages/hunk/src/main.tsx        final CLI composition and entrypoint
```

Other workspaces are bounded units rather than one tier in that stack:

- `packages/hunk-{git,jj,sapling}` implement private bundled providers through
  `hunkdiff/extension` and explicit `@hunk/vcs/*` leaves.
- `packages/session-broker{,-core,-bun,-node}` form a runtime-neutral broker stack plus listener
  adapters. They do not import Hunk source internals.
- `packages/term-video` owns terminal capture tooling and does not import Hunk source internals.

Current `core/` modules are `changeset/`, `documents/`, `history/`, `install/`, `patch/`, `process/`, `review/`,
`run/`, `theme/`, `vcs/`, and `watch/`. Its root contains `bootstrap.ts`, `liveComments.ts`,
`reviewDescriptor.ts`, and `reviewDigest.ts` plus tests.

Intentional exceptions, allowed by the rules:

- `packages/hunk/src/opentui` imports `packages/hunk/src/ui` internals: it is a packaging facade whose job is re-export.
- `packages/hunk/src/hunk-review` imports `packages/hunk/src/session/agent`: the skill document is generated from the agent
  surface by design.
- Tests are excluded: they are colocated and free to reach across boundaries.

Complete-document browsing uses `core/documents/source.ts` rather than `DiffFile` or review
state. `app/documents` owns filesystem I/O and optional Git metadata; `ui/documents` consumes
only document capabilities and finalized preferences. `HunkSessionHost` routes documents,
history, and review in one renderer lifetime. Language selectors live in `core/documents`;
complete-document syntax paint lives in `ui/syntax`, and native word-wrap measurement lives in
`ui/text`. Review/file-view consumers use those same services. The existing public extension
review contracts and broker protocol remain unchanged.

## Module interiors

Tier rules say which trees may reach each other; interior rules say which _files_ in a tree
outsiders may name. A module's interior is enforced the same way as a tier — one rule per
protected file, `from` everything outside the module's directory, `to` the file — so an
accidental reach-in fails `bun run deps:check` instead of quietly becoming API.

Two supporting rules keep the interiors honest:

- **`no-dead-modules`** flags any module under `packages/hunk/src/` that no entry point reaches
  (`main.tsx`, `highlightWorkerEntry.ts`, the `opentui` and `extension-api` facades, and the
  skill generator). It uses `reachable: false` rather than `orphan`, which only catches fully
  disconnected files and so misses dead code that still imports. A hit is deleted, or — when
  tests are its only genuine consumer — listed in the rule's `TEST_ONLY_MODULES` allowlist
  with the reason. That allowlist is **shrink-only**, like the baseline.
- **`core-leaves-stay-below-bootstrap`** freezes the cycle fix below: `core/bootstrap.ts`
  describes one composed launch, so the core module paths named by the rule may not import it back.
  (Through phase 3 this rule was `core-leaves-never-reimport-types` and guarded
  `core/types.ts`; phase 4 melted that shell and repointed the rule at what replaced it.)

## Historical migration record

The following dated phases explain why the current rules exist. Paths and inventories describe the
repository at that phase unless a later note updates them.

Phase 0 (2026-08-17) established the mechanism: it deleted `core/review/address.ts` (a
speculative primitive with no consumers), added the two rules above, and froze the first
interior — `core/review/reducer.ts` is importable only from within `core/review/`, because
callers state intent and `planReviewIntent` owns the transition. Later phases extend the same
pattern across `packages/hunk/src/core` as its subdirectories take shape; the review model's named modules
(`document`, `identity`, `geometry`, `state`, …) stay public by design.

Phase 1 (2026-08-17) grouped the changeset model and its acquisition pipeline — twelve loose
files at `core/*` root — into `core/changeset/`, with the surface split enforced by
`changeset-internals-stay-in-module`:

- **Public:** `model` (the `Changeset` / `DiffFile` / `SidecarContext` shapes), `loaders`
  (every input source, plus the app bootstrap built around one), `diffFile`, `fileSource`,
  `fileLanguage`, `binary`, `diffPaths`, `hunkHeader`, `hunkSummary`.
- **Interior:** `fromPatch` turns patch text into the model and is reached through `loaders`;
  `fileLanguageLookup` is the only reader/writer of Pierre's process-global extension table
  and the import that drags in the diff engine; `sidecar` reads `--agent-context` as one step
  of acquiring a changeset.

The renames are path-only — `changeset.ts` → `changeset/model.ts`, `changesetLoaders.ts` →
`changeset/loaders.ts`, `changesetFromPatch.ts` → `changeset/fromPatch.ts`, the rest keep their
basenames — and no exported symbol changed. `core/types.ts` still re-exports the changeset and
sidecar shapes for legacy import sites (since phase 4: those sites name `changeset/model`
directly); it names `changeset/model`, which is public, so the interior rule needs no
exception. `core/patch/` stayed where it is: it was already coherent.

Phase 2 (2026-08-17) grouped **how a run is asked for** into `core/run/`: the command
inputs (`commandInputs`), the layered config resolver (`config`), the app command catalog
(`commandCatalog`), the invocation errors (`errors` — "a failure Hunk raises because of how it
was invoked"), launch-scoped experimental features (`experimental`), XDG/app paths (`paths`),
tab-width validation (`tabWidth`), reload eligibility (`inputReload`), and the CLI version
(`version`). The move is path-only; no exported symbol changed.

Every one of the nine is **public**: each has production importers outside the module, so this
phase adds no `run-internals-stay-in-module` rule. Its value is the grouping plus
extending the freeze: `core-leaves-never-reimport-types` now names
`core/run/commandInputs.ts` in place of the old root path. Only `commandInputs` is a
types-leaf — `config`, `experimental`, and `inputReload` import `core/types` legally, since
`core/types` re-exports from `commandInputs` and never the other way round. (Since phase 4
there is no shell to import: those three name `commandInputs` directly, and `config` declares
the config-owned shapes itself.)

`commandInputs` also absorbed the CLI-input half that was still declared in `core/types.ts`:
`HelpCommandInput`, `PagerCommandInput`, `DaemonServeCommandInput`, the whole
`Session*CommandInput` family with `SessionSelectorInput` / `SessionCommandOutput` /
`SessionCommentApplyItemInput`, `MarkupRenderCommandInput`, `MarkupGuideCommandInput`, the
`Extension*CommandInput` family, and `ParsedCliInput`. Two small aliases came with them because
those inputs name them and the leaf may not import `core/types` back:
`SessionCommentListType` and the `ReviewNoteSource` it unions over. `core/types.ts` re-exports
all of them, so no import site changed at the time; phase 4 moved the import sites onto
`commandInputs` and deleted the re-exports.

Phase 3 (2026-08-17) grouped **the process and terminal a run lives in** into `core/process/`:
TTY capability detection and runtime CLI-input resolution (`terminal`), the external pager and
its plain-text fallback (`pager`), SIGTSTP/SIGINT job control (`jobControl`), ordered session
teardown (`shutdown`), `.hunk`/VCS project-root discovery (`projectRoot`), the atomically
written app-state file (`appStateFile`), the version-check notice built on it (`updateNotice`),
and the startup-notice shape every tier reports through (`startupNotice`). The move is
path-only; no exported symbol changed, and `core/types.ts` now re-exports `StartupNotice` from
`process/startupNotice` (a re-export nothing ever imported, deleted unused in phase 4).

All eight are **public** — each has production importers outside the module — so this phase adds
no `process-internals-stay-in-module` rule; the grouping is its value. The audiences are worth
naming, because they are why these files never belonged at `core/*` root together with the review
model: `terminal`, `jobControl`, `shutdown`, and `updateNotice` serve the interactive surface;
`pager` serves the CLI entry and the startup plan; `projectRoot` and `appStateFile` serve the
extension host and config resolution.

After this phase `packages/hunk/src/core/` root holds only `types.ts`, `reviewDigest.ts`, and `liveComments.ts`
beside the seven subdirectories. Phase 4 melts what is left of `core/types.ts`.

Phase 4 (2026-08-17) melted that shell. `core/types.ts` had stopped declaring most of what it
exported: 147 files imported it, almost all for names phases 1–3 had already moved elsewhere,
and the re-export list was the only thing holding those import sites to a module that no longer
owned the answer. A grab-bag that re-exports is still a grab-bag — every importer binds to it,
so nothing downstream reveals which module it actually depends on.

Every import site was retargeted at the declaring module (mixed statements split one target per
module), the re-exports were deleted, and the file was renamed `core/types.ts` →
`core/bootstrap.ts` for what is genuinely left: `AppBootstrap` and `ReloadContext`, the contract
a composed launch hands the interactive shell. The stragglers it still declared went to the
module that owns their behaviour, one home each:

- `TerminalThemeMode` → `core/theme/detection.ts`, which probes the terminal for it and had
  been re-exporting the name from the shell.
- `ExtensionsConfig`, `UserKeyBinding`, `PersistedViewPreferences` → `core/run/config.ts`,
  which resolves `[extensions]`, `[keybindings]`, and the persisted view options.
- `UserNoteLineTarget` → `core/liveComments.ts`, beside `DiffSide` and `CommentTargetInput`: it
  is the line a user note hangs on, and every consumer reaches it through note code.

Deleting the re-exports made one hidden dependency visible: `core/review/annotations.ts` names
`AgentAnnotation`, which is declared in `packages/hunk/src/extension-api/types.ts` because it is
simultaneously an internal model type and part of the published contract. Routing that through
`core/types.ts` had disguised it as a core-local import, and `scripts/quality/source-boundaries.test.ts`
("keeps the review model contained in core") caught it the moment the disguise came off. The
allowance is now explicit and narrow — that one file, not the tree — and it cannot widen the
seam, since `extension-api-is-import-free` forbids `extension-api/types.ts` any import at all.

Fan-in tells the story: 147 importing files became 28 (13 outside tests) — the review stream,
the diff renderer, and the session surfaces never needed the bootstrap contract, only the
changeset and command-input models they now name. `core/bootstrap.ts` imports downward into
`changeset/model`, `run/commandInputs`, `run/config`, `process/startupNotice`,
`theme/detection`, and `vcs/types`, and `core-leaves-stay-below-bootstrap` forbids the reverse
edge from every module directory. One exception is carved out and named in the rule:
`core/changeset/loaders.ts` returns an `AppBootstrap` from `loadAppBootstrap`, so it names the
shape it assembles; that function is composition living in the domain tier, and moving it to
`packages/hunk/src/app` retires the exception.

`packages/hunk/src/core/` root now holds `bootstrap.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the
eight module directories.

Phase 5 (2026-08-18) grouped **how this binary was installed and how it gets replaced** into
`core/install/`: install-source detection (`installSource` — which channel owns the executable),
per-channel release lookup (`latestRelease`), and the `hunk update` execution (`selfUpdate`).
These arrived with the self-update feature as `core/process/` files because the startup update
notice lived there, but they are one feature family about the install lifecycle, not about the
process a run lives in. `process/updateNotice` stays where phase 3 put it — it is a
startup-notice producer built on `appStateFile` — and consumes `core/install` for detection and
release lookup. The move is path-only; no exported symbol changed.

## Historical snapshot (2026-08-17, v0.19.0)

This snapshot records the migration baseline; it is not a count of current main. At that point the
graph contained 331 production modules, 1322 internal edges, **zero boundary violations and zero import
cycles** — the baseline is empty. (The edge count grew from 1282 in phase 4: import sites that
used to funnel through one re-export shell now name the modules they actually depend on, so the
same dependencies are finally visible in the graph.) The initial audit (2026-08-16) found 28
violations in five
clusters and 5 file-level cycles; all were repaid in the same change series that introduced the
rules:

- **Cycles.** Each cycle was a type-only back-edge from a lower module into a grab-bag above
  it. The cuts: `core/types.ts` gave its changeset model to `core/changeset.ts` (since phase 1,
  `core/changeset/model.ts`) and its command-input model to `core/commandInputs.ts` (since phase
  2, `core/run/commandInputs.ts`; re-exported from `core/types` so import sites kept
  working, until phase 4 melted that shell into `core/bootstrap.ts` and moved the sites onto the
  declaring modules); the diff row model moved to
  `ui/diff/diffRowModel.ts`; the worker's
  compact encoder was retyped structurally (`HighlightedHastLines`); `HunkSessionBrokerClient`
  moved beside the client class it aliases; `CopySelectedRowRange` moved into
  `ui/lib/diffSpatial.ts`; `extensions/notifications.ts` now imports `ExtensionNotifyType`
  from its declaring module.
- **`packages/hunk/src/core/cli.ts` → `packages/hunk/src/app/cli.ts`.** CLI parsing that registers every tier's command
  surface (including `hunk session *` from `session/agent/surface.ts`) is composition, not
  domain — moving it made the core→session edges legal app→session edges.
- **`packages/hunk/src/session/app/` → `packages/hunk/src/app/session/`.** The mounted-review registration, bridge, and
  reload-authorization modules compose the app process with the session broker, and nothing
  inside `packages/hunk/src/session` imported them — they were app-tier code homed on the wrong side.
  Moving the directory removed every session→app edge at once.
- **`packages/hunk/src/lib/reviewDigest.ts` → `packages/hunk/src/core/reviewDigest.ts`.** The Node digest implementation
  is review-semantic and platform-bound; core root (Node-full, outside the platform-free
  `core/review/` seam) is its tier.
- **`ui/lib/reviewState.ts`** resolves session-daemon navigation for the adapter hooks and is
  now a named entry in the adapter allowlist rather than an accidental reach-in.
- **The bundled sidebar's `packages/hunk/src/ui` imports are documented design, not debt.** Its module
  header defines the dogfooding boundary as the published props contract (data, actions,
  theme); rendering helpers are host code. The rules now encode exactly that:
  `packages/hunk/src/extensions/default/ui/` may consume `packages/hunk/src/ui`, and still may never touch
  `packages/hunk/src/app`/`packages/hunk/src/session`.

## After the baseline: next targets

The tier rules now hold with no exceptions. Two follow-ups are worth doing next:

1. **Give `packages/hunk/src/core` an interior.** _Done (phases 0–4, see Module interiors)._ Every group is a
   module directory — `review/`, `vcs/`, `theme/`, `watch/`, `patch/`, `changeset/`,
   `history/`, `install/`, `run/`, `process/` — and `core/*` root contains `bootstrap.ts`,
   `reviewDigest.ts`, `reviewDescriptor.ts`, and `liveComments.ts`, with no grab-bag re-export shell. What remains is per-file public
   surfaces for the modules that never got one: `changeset` has
   `changeset-internals-stay-in-module` and `review` has `review-reducer-is-module-internal`,
   while `run`, `process`, `theme`, `vcs`, `watch`, and `patch` are still public in full
   because every file in them has an outside importer today. Two named follow-ups: move
   `loadAppBootstrap` out of `core/changeset/loaders.ts` into `packages/hunk/src/app` (it is composition, and
   it is the one exception `core-leaves-stay-below-bootstrap` has to carve out), and split
   `core/run/config.ts`, whose readers reach it for three unrelated reasons — the
   resolved `HunkConfigResolution`, the persisted view preferences, and the extension/keybinding
   tables. The review seam's named modules (`document`, `geometry`, `state`, …) stay public;
   their helpers become internal.
2. **Tighten the adapter allowlist.** `ui-couples-to-session-via-adapters` names its current files
   in `UI_SESSION_ADAPTERS` inside `.dependency-cruiser.cjs`. As session coupling consolidates into
   `useTerminalReview` / `useHunkSessionBridge`, shrink that list rather than copying a count here.

---

### Open (`docs/open.md`)

# Complete-document browsing

`hunk open [path]` views a file or browses a directory. The default path is `.`. It requires an
interactive terminal, but not a repository or a diff.

```bash
hunk open README.md
hunk open ~/project
hunk open hypr/ --theme nord
hunk open file.ts --wrap --tab-width 4
```

## Controls

- Arrow keys or `j`/`k`: move through the focused tree or document.
- Enter/Space: open a file or expand/collapse a directory.
- Left/Right: collapse/expand the selected directory, or scroll the focused document horizontally.
- Tab: switch tree/document focus. `s`: toggle the tree.
- PageUp/PageDown, `g`/`G`, Home/End: scroll the focused surface.
- `i`: toggle hidden and Git-ignored entries together. Hidden entries are names starting with `.`.
- `e`: edit a private copy of the displayed regular file in `$EDITOR`, using the visible line as
  the editor target; save back when the editor exits.
- `t`, `l`, `w`, `?`, `r`, `q`: themes, line numbers, wrapping, help, refresh, quit.
- File/View/Help menus provide the same actions with mouse support. F10 opens the menu by keyboard.

Common app/view commands keep their existing command ids. Document actions use `hunk.documents.*`:
`activate`, `edit`, `stepDown`, `stepUp`, `pageDown`, `pageUp`, `jumpToTop`, `jumpToBottom`,
`scrollLeft`, `scrollRight`, and `toggleExcluded`. User `[keybindings]` remaps apply to menus and help.

```toml
[open]
theme = "nord"
wrap_lines = true

[keybindings]
"hunk.documents.stepDown" = "ctrl+n"
"hunk.documents.stepUp" = "ctrl+p"
```

## Filesystem policy

Only immediate children of expanded directories are read. The document viewer mounts a bounded
row window and reads only the selected document. Refresh preserves the displayed document and
scroll position; selecting a directory does not replace the document already displayed.

In a Git worktree, Git supplies ignore rules and lightweight status markers. Metadata queries are
optional, bounded, and asynchronous; repository fsmonitor hooks and clean/process filters are
disabled. Status queries do not recurse into submodules or fetch missing objects. Ordinary
directory browsing works without Git. An interrupted or failed ignore query makes that directory
unavailable rather than treating partial ignore output as complete. Hidden and
ignored entries are initially excluded, but an explicitly named hidden file still opens normally.

Symlinks are listed but not followed, including explicitly opened links and symlinked descendants.
Devices and other non-regular entries are not opened. Binary/non-UTF-8 files, files over 1 MiB,
and missing or unreadable entries show a placeholder. Each directory is limited to 10,000 entries
and at most 128 directories may be expanded. Source reads remain bounded when a file grows while
it is being read. Terminal control sequences are stripped before painting.

On Linux, directory enumeration uses the checked directory's retained handle. Other platforms
recheck ancestry and directory identity before publishing entries, but portable Node APIs do not
provide handle-relative directory enumeration. Browsing is not a sandbox against a hostile process
that can repeatedly replace and restore ancestors; use it on trusted local directories.

Editors receive a private temporary copy, not a checked collection path. Hunk retains the original
file handle and checks that the file identity and content still match before writeback. Detected
replacement or modification cancels writeback and retains the edited copy at the path shown in the
notice. Editor errors also retain that copy. Known GUI editors receive `--wait`; custom editor
wrappers must wait until editing finishes. Each document browser permits one active editor action;
repeated editor actions in that browser are rejected while it is active. Quitting waits for the editor transaction to settle, and late
failures/recovery paths are printed after the terminal is restored. `$EDITOR` itself is trusted
code, not sandboxed.

Updated Hunk sessions in the same OS account and process namespace coordinate validation and writeback with
private, inode-keyed save claims under the OS account home's `.config/hunk/document-save-claims/`
directory. This state directory does not create a `.hunk` project marker in the account home.
This location is independent of per-session `HOME`, XDG and temporary-directory overrides. Account
lookup uses OS records, including built-in PowerShell without profiles on Windows; lookup or private
directory failures refuse writeback and retain the edited copy. Competing
saves are rejected with their editor copies retained; claims left by definitely dead processes are
removed. An unrelated process reusing an owner's PID can conservatively delay saving until it exits.
Save claims do not coordinate different hosts/accounts or container process namespaces, older Hunk
versions, or non-Hunk writers.
Those writers can still race the final conflict check, and in-place writeback is not crash-atomic.

Expanded directories and the displayed file's parent are observed for changes, covering atomic
file replacements and deletions. Watch notifications are debounced; manual and watch refreshes
serialize. A failed watcher can be worked around with `r`. Shutdown cancels pending source work,
releases observers, and waits for started reads to settle before tearing down the terminal.

## Architecture and current boundaries

`core/documents` defines opaque entry identities, complete text and availability results, and lazy
source capabilities. Filesystem operations and Git metadata live in `app/documents`; the browser
controller and geometry live in `ui/documents`. The existing session host routes this surface
alongside history and review, reusing terminal lifecycle, theme ownership, menu components,
keymaps, editor launching, syntax paint, and exact native text measurement.

Browsing never constructs `DiffFile`, hunks, or `ReviewDocument`, and it does not publish a review
session to the broker. User extensions remain review-specific and are not loaded by `open`.
The source contract is currently internal; a future public document extension API should expose
explicit capabilities without weakening review navigation or workspace-write policies.

Stdin, multiple path arguments, and `file:line` addressing are deferred. The browser writes only
when saving a private copy from an explicitly launched external editor; it does not reopen the
collection path for writeback.

---

### Opentui Component (`docs/opentui-component.md`)

# OpenTUI component

`hunkdiff/opentui` exports reusable terminal diff components built from the same renderer as the Hunk CLI.

Use `HunkDiffView` when you want a batteries-included single-file diff, or compose the lower-level primitives when you want to build your own Hunk-like review UI without Hunk's sidebar, menus, global keyboard shortcuts, or session behavior.

## Install

```bash
npm i hunkdiff @pierre/diffs@1.3.5 @opentui/core@^0.5.6 @opentui/react@^0.5.6 react
```

`hunkdiff` declares Pierre diffs, OpenTUI, and React as peer dependencies, so install them in your app. Pierre diffs is optional for CLI-only installs but required when importing `hunkdiff/opentui`.

## Quick start

```tsx
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { HunkDiffView, createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";

const metadata = parseDiffFromFile(
  {
    cacheKey: "before",
    contents: "export const value = 1;\n",
    name: "example.ts",
  },
  {
    cacheKey: "after",
    contents: "export const value = 2;\nexport const added = true;\n",
    name: "example.ts",
  },
  { context: 3 },
  true,
);

const renderer = await createCliRenderer({
  useAlternateScreen: true,
  useMouse: true,
  exitOnCtrlC: true,
});
const root = createRoot(renderer);

root.render(
  <HunkDiffView
    diff={createHunkDiffFile({
      id: "example",
      metadata,
      language: "typescript",
      path: "example.ts",
    })}
    layout="split"
    width={88}
    theme="midnight"
  />,
);
```

In a real app, derive `width` from your layout or `useTerminalDimensions()`.

## Convenience vs primitives

### `HunkDiffView`

`HunkDiffView` renders one file and can own an OpenTUI `scrollbox`:

```tsx
<HunkDiffView diff={file} width={88} canonicalLayout="split" scrollable />
```

Use it when you just want a drop-in diff viewer.

### `HunkDiffBody`

`HunkDiffBody` renders only the diff body for one file. It does not create a scrollbox, file nav, keyboard shortcuts, menus, or session bridge behavior:

```tsx
<scrollbox width="100%" height="100%" scrollY>
  <HunkDiffBody file={file} width={88} canonicalLayout="unified" selectedHunkIndex={2} />
</scrollbox>
```

Use it when your app owns scrolling or surrounding layout.

### `HunkDiffFileHeader`

`HunkDiffFileHeader` renders Hunk's compact file label/stats header:

```tsx
<HunkDiffFileHeader file={file} width={88} onSelect={() => selectFile(file.id)} />
```

### `HunkReviewStream`

`HunkReviewStream` renders a top-to-bottom multi-file review stream without Hunk's app shell, chrome, keybindings, or scroll owner:

```tsx
<scrollbox width="100%" height="100%" scrollY>
  <HunkReviewStream
    files={files}
    width={terminal.width}
    layout="split"
    selection={{ fileId, hunkIndex }}
    onSelectionChange={({ fileId, hunkIndex }) => {
      setFileId(fileId);
      setHunkIndex(hunkIndex);
    }}
  />
</scrollbox>
```

Use it when you want Hunk's main review stream but your own navigation, chrome, scrolling, and keybindings.

### `HunkFileNav`

`HunkFileNav` renders Hunk's file navigation list as an optional primitive. It does not render borders, outer padding, or a scrollbox; host apps own surrounding chrome and scrolling.

```tsx
<scrollbox width={32} height="100%" scrollY>
  <HunkFileNav
    files={files}
    selectedFileId={fileId}
    width={32}
    onSelectFile={(nextFileId) => setFileId(nextFileId)}
  />
</scrollbox>
```

## Building file inputs

The public file model is intentionally higher-level than Hunk's internal renderer rows. Row models are not exported.

```ts
type HunkDiffFileInput = {
  id: string;
  metadata: FileDiffMetadata;
  language?: string;
  path?: string;
  previousPath?: string;
  patch?: string;
  stats?: { additions: number; deletions: number };
  isBinary?: boolean;
  isTooLarge?: boolean;
  isUntracked?: boolean;
  statsTruncated?: boolean;
};

type HunkDiffFile = Omit<HunkDiffFileInput, "stats"> & {
  stats: { additions: number; deletions: number };
};
```

Components accept `HunkDiffFileInput` directly. Use `createHunkDiffFile(...)` when you want a normalized `HunkDiffFile` with paths and stats filled in once:

```tsx
import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";

const file = createHunkDiffFile({
  id: "example",
  metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
  path: "example.ts",
  language: "typescript",
});
```

### From before/after contents

Use `parseDiffFromFile(...)` when you already have the old and new file contents.

```tsx
import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";

const file = createHunkDiffFile({
  id: "example",
  metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
});
```

### From unified diff text

Use `createHunkDiffFilesFromPatch(...)` for a quick multi-file patch path:

```tsx
import { createHunkDiffFilesFromPatch } from "hunkdiff/opentui";

const files = createHunkDiffFilesFromPatch(patchText, "example:patch");
```

If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re-exported.

## Common props

Use `canonicalLayout` in new integrations. The legacy `layout` prop and `HunkDiffLayout` type keep
their original `split | stack` shape so existing exhaustive TypeScript consumers continue to
compile; when both props are present, `canonicalLayout` wins.

| Prop                 | Type                                                                                                                                                       | Default      | Notes                                                                               |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------- |
| `canonicalLayout`    | `"split" \| "unified"`                                                                                                                                     | `"split"`    | Chooses side-by-side or unified rendering in new integrations.                      |
| `layout`             | `"split" \| "stack"`                                                                                                                                       | `undefined`  | Legacy source-compatible input; `stack` renders as unified.                         |
| `width`              | `number`                                                                                                                                                   | —            | Required content width in terminal columns.                                         |
| `theme`              | `"graphite" \| "midnight" \| "paper" \| "ember" \| "catppuccin-latte" \| "catppuccin-frappe" \| "catppuccin-macchiato" \| "catppuccin-mocha" \| "zenburn"` | `"graphite"` | Matches Hunk's built-in themes.                                                     |
| `showLineNumbers`    | `boolean`                                                                                                                                                  | `true`       | Toggles line-number columns.                                                        |
| `showHunkHeaders`    | `boolean`                                                                                                                                                  | `true`       | Toggles `@@ ... @@` hunk header rows.                                               |
| `tabWidth`           | `number`                                                                                                                                                   | `4`          | Sets source-code tab stops from 1 to 16 columns.                                    |
| `fileGap`            | `number`                                                                                                                                                   | `1`          | Rows between files in `HunkReviewStream`, including the `─` rule. `0` hides it.     |
| `hunkGap`            | `number`                                                                                                                                                   | `0`          | Blank rows before each hunk after the first.                                        |
| `showFileSeparators` | `boolean`                                                                                                                                                  | `true`       | Toggles separator rows between files in `HunkReviewStream`.                         |
| `wrapLines`          | `boolean`                                                                                                                                                  | `false`      | Wraps long lines instead of clipping horizontally.                                  |
| `horizontalOffset`   | `number`                                                                                                                                                   | `0`          | Scroll offset for non-wrapped code rows.                                            |
| `highlight`          | `boolean`                                                                                                                                                  | `true`       | Enables syntax highlighting.                                                        |
| `selectedHunkIndex`  | `number`                                                                                                                                                   | `0`          | Highlights one hunk as the active target for single-file components.                |
| `scrollable`         | `boolean`                                                                                                                                                  | `true`       | `HunkDiffView` only; primitives should be wrapped in OpenTUI scrollbox when needed. |

## Other exports

- `parseDiffFromFile`
- `parsePatchFiles`
- `FileDiffMetadata`
- `createHunkDiffFile`
- `createHunkDiffFilesFromPatch`
- `countHunkDiffStats`
- `HUNK_DIFF_THEME_NAMES`
- `HunkDiffThemeName`
- `CanonicalHunkDiffLayout`
- `HunkDiffLayout`
- `HunkDiffFile`
- `HunkDiffFileInput`
- `HunkDiffStats`
- `HunkDiffSelection`
- component prop types

`parseDiffFromFile`, `parsePatchFiles`, and `FileDiffMetadata` are re-exported from the `@pierre/diffs` peer dependency so the component and your app share one diff implementation.

## Examples

- Runnable demo overview: [`examples/README.md`](../examples/README.md)
- Component demos: [`examples/7-opentui-component/README.md`](../examples/7-opentui-component/README.md)

The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui`.

---

### Selective Extension Control Plan (`docs/selective-extension-control-plan.md`)

# Selective extension control plan

## Status

Implemented in this branch for the shared selection model, configuration and CLI overrides,
`hunk.gh`, and user extensions (phases 1–3). Bundled VCS/UI selection and management commands
(phases 4–5) remain follow-up work; the stable ids below reserve their intended names without
claiming those factories are filtered yet.

## Goal

Let users disable or re-enable one Hunk extension without uninstalling it, including extensions
compiled into Hunk. Apply the decision before extension code executes, preserve the existing trust
model, and keep `--no-extensions` compatible with its current meaning.

The first bundled consumer is `hunk gh`, but the mechanism must also cover bundled VCS and UI
extensions and user-installed extensions without introducing feature-specific settings.

## Product decisions

### Stable selection identities

Give every independently selectable bundled extension a stable host-owned identity:

| Capability        | Selection ID       |
| ----------------- | ------------------ |
| GitHub commands   | `hunk.gh`          |
| Git provider      | `hunk.vcs.git`     |
| Jujutsu provider  | `hunk.vcs.jj`      |
| Sapling provider  | `hunk.vcs.sl`      |
| Content search    | `hunk.search`      |
| Files pane        | `hunk.files`       |
| Review-info panes | `hunk.review-info` |

User extensions keep their discovered IDs, such as `review-triage`.

A selection identity is not a registration namespace. Bundled UI registrations may continue to
own commands and panes under the `hunk` vendor namespace while being selected independently. This
avoids changing public command IDs such as `hunk.search.find` or pane IDs such as `hunk:files`.

### Configuration

Add an exact-ID deny-list to `[extensions]`:

```toml
[extensions]
enabled = true
disabled = ["hunk.gh", "hunk.search"]
paths = ["~/dev/my-extension"]
```

Keep the existing master switch:

```toml
[extensions]
enabled = false
```

`enabled = false` continues to disable user-extension discovery and loading. Bundled extensions
remain available unless selected by `disabled`.

Do not interpret `enabled` inside `[extension.<id>]`. Those tables remain opaque configuration
owned by the extension:

```toml
[extension.review-triage]
some_setting = true
```

Start with exact IDs rather than wildcard rules. Exact matching keeps diagnostics, layering, and
configuration editing deterministic. A future release can add patterns if concrete use cases
justify them.

### Configuration layering

Combine user and repository disabled lists as a union:

```text
effective disabled = user disabled ∪ repository disabled ∪ CLI disables
```

A repository may further restrict extension execution but cannot re-enable an extension disabled
by the user. This keeps repository-controlled configuration from escalating extension authority.

An explicit CLI enable is the only one-run override:

```text
effective enabled override = explicit CLI enables
```

Within CLI arguments, the last enable/disable occurrence for one identity wins.

### CLI overrides

Add repeatable global options:

```bash
hunk --disable-extension hunk.gh gh pr 123
hunk diff --disable-extension hunk.search
hunk diff --enable-extension hunk.search
```

Multiple identities can be selected:

```bash
hunk diff \
  --disable-extension hunk.search \
  --disable-extension review-triage
```

Keep `--no-extensions` as the hard one-run switch for user extensions. It does not disable bundled
extensions. An explicit `--extension <path>` still obeys the effective disabled set; pair it with
`--enable-extension <id>` to override a persistent disable deliberately.

### Management commands

Extend extension management with inspection and persistent state changes:

```bash
hunk extension list
hunk extension list --builtin
hunk extension list --all

hunk extension disable hunk.gh
hunk extension enable hunk.gh

hunk extension disable review-triage --repo
hunk extension enable review-triage --repo
```

The default mutation scope is the user config. `--repo` edits `.hunk/config.toml`. `remove` remains
limited to installed extensions; bundled extensions can be disabled but not removed.

List output should expose identity, source, and effective state:

```text
ID                    SOURCE      STATE
hunk.gh               bundled     disabled (user config)
hunk.search           bundled     enabled
hunk.vcs.git          bundled     enabled
review-triage         installed   disabled (repo config)
```

Config mutation must preserve unrelated TOML content and use the same serialization and atomic-write
policy as other Hunk-owned config updates. If no safe writer exists when this work begins, ship
read-only config plus one-run flags first and defer `extension enable/disable` rather than adding a
lossy editor.

### Disabled command diagnostics

Keep enough static bundled metadata to recognize a command owned by a disabled extension. Do not
report it as an unknown command:

```text
hunk: Extension "hunk.gh" is disabled.

Enable it for this run:
  hunk --enable-extension hunk.gh gh pr 123

Enable it permanently:
  hunk extension enable hunk.gh
```

Bare `hunk --help` remains static and may continue listing bundled commands. It should not load
configuration or extension code merely to annotate disabled entries.

### VCS providers

Allow bundled VCS providers to be disabled. Raw files and patches must continue to work without a
VCS provider. When repository detection finds only a disabled provider, report that provider and an
actionable one-run enable command:

```text
No enabled VCS provider recognizes this repository.

Disabled matching provider: hunk.vcs.jj
Enable it with:
  hunk --enable-extension hunk.vcs.jj diff
```

Do not model indispensable host machinery as an extension. Anything that cannot sensibly be
disabled should remain host code rather than receive a selectable identity.

## Architecture

### Bundled definition catalog

Introduce a renderer-free catalog of bundled extension definitions. Each definition carries:

```ts
interface BundledExtensionDefinition {
  selectionId: string;
  registrationId: string;
  sourcePath: string;
  kind: "core" | "vcs" | "ui";
  factory: ExtensionFactory;
  commands?: readonly string[];
}
```

`selectionId` controls enablement. `registrationId` preserves public command, pane, and config
namespaces. `commands` is bounded static metadata used only for disabled-command diagnostics; the
factory remains authoritative when enabled.

The catalog must not merge VCS, core CLI, and UI lifecycles. Existing composition owners continue
to create their own registries:

- bundled VCS loads before configuration-dependent repository resolution;
- bundled core CLI capabilities use the session-owned extension lifetime;
- bundled UI remains process-owned and renderer-local.

All three consume the same resolved selection policy before running factories.

### Selection policy

Add a small platform-free policy module that accepts:

- known bundled definitions;
- discovered user candidates;
- user-layer disabled IDs;
- repository-layer disabled IDs;
- ordered CLI enable/disable operations;
- the existing user-extension master switch.

It returns an immutable decision per identity with the reason and source:

```ts
interface ExtensionSelectionDecision {
  id: string;
  enabled: boolean;
  source: "default" | "user-config" | "repo-config" | "cli" | "master-switch";
}
```

Keep this policy separate from filesystem discovery, trust decisions, module imports, and registry
mutation. Callers apply decisions before import or factory execution.

### Loading order

For every startup path:

1. Resolve user and repository configuration.
2. Parse ordered CLI extension overrides.
3. Resolve bundled selection decisions.
4. Run only enabled bundled factories in their existing lifecycle owners.
5. Discover user candidates when the existing master switch permits it.
6. Resolve candidate IDs and filter disabled candidates before module import or trust prompting.
7. Trust-gate and import only remaining user candidates.
8. Resolve registration collisions among enabled extensions.

A disabled user extension must not be imported. A disabled repository extension must not trigger a
trust prompt. A statically bundled module may exist in the binary, but its factory must not run and
it must register no handlers or resources.

### Reload and lifetime invariants

Include the resolved selection snapshot in extension load compatibility. A change to effective
enablement requires registry replacement even when discovered candidates and extension config are
unchanged.

Preserve these invariants:

- delegated `hunk gh` patches live until the session owning the enabled factory shuts down;
- disabling `hunk.gh` on a later startup never runs its factory;
- disabling an active extension during config-driven reload revokes its capabilities before
  shutdown;
- staged root refinement does not execute an unchanged enabled factory twice;
- registry retirement remains idempotent.

## Implementation phases

### Phase 1: selection model and configuration

1. Add `disabled: readonly string[]` to resolved extension configuration.
2. Parse exact non-empty IDs from user and repository `[extensions]` tables.
3. Add ordered CLI override representation to command inputs.
4. Implement and unit-test the pure selection policy.
5. Document precedence, repository narrowing, and the distinction from `[extension.<id>]`.

Acceptance:

- invalid list members are rejected or normalized consistently with existing config policy;
- user and repository disables union correctly;
- CLI enable and disable operations resolve last-wins per ID;
- existing configs behave identically when `disabled` is absent.

### Phase 2: bundled definitions and `hunk.gh`

1. Give the bundled GitHub definition selection ID `hunk.gh`.
2. Keep its current registration namespace and session-owned resource lifetime.
3. Filter it before `createGitHubPrExtension` runs.
4. Retain static ownership metadata for `gh` diagnostics.
5. Add the disabled-command error and one-run recovery suggestion.

Acceptance:

- `[extensions] disabled = ["hunk.gh"]` prevents `gh` registration;
- `hunk gh ...` reports disabled, not unknown;
- `--enable-extension hunk.gh` restores it for one invocation;
- `--no-extensions` alone does not disable it;
- no GitHub temporary directory or network request is created while disabled.

### Phase 3: user extensions

1. Apply exact-ID selection after discovery identifies a candidate and before import.
2. Ensure explicit `--extension` paths obey the same policy.
3. Suppress repo trust prompts when every repo candidate is disabled.
4. Carry selection state through staged loading and registry replacement.

Acceptance:

- disabled modules cannot run top-level code;
- disabled repo extensions do not prompt for trust;
- explicit paths require `--enable-extension <id>` when persistently disabled;
- enabled candidates retain existing ordering and collision behavior.

### Phase 4: bundled UI and VCS

1. Add selection identities to the bundled VCS definitions.
2. Filter providers before catalog construction and project-root detection.
3. Add actionable diagnostics when only disabled providers match.
4. Add selection identities to independently optional bundled UI factories.
5. Keep registration IDs stable so commands, panes, and keybindings do not change.

Acceptance:

- disabling JJ or Sapling removes it from detection without affecting Git;
- explicit `--vcs <id>` reports when the corresponding extension is disabled;
- disabling search removes its commands and marks while review remains usable;
- disabling review-info or files panes does not change unrelated geometry or commands.

### Phase 5: management UX

1. Extend `extension list` with bundled and effective-state views.
2. Add safe user-config mutations for `extension enable/disable`.
3. Add explicit `--repo` mutation scope only if TOML preservation is reliable.
4. Surface stale IDs in listings without treating them as fatal configuration errors.

Acceptance:

- list output distinguishes bundled, installed, config-path, flag, and repo sources;
- mutation preserves comments and unrelated config or refuses safely;
- enabling removes the relevant disable entry rather than writing extension-owned config;
- bundled extensions cannot be removed accidentally.

## Documentation updates

Update together:

- `docs/extensions.md` for configuration, flags, management commands, and trust behavior;
- `docs/extension-architecture.md` for selection identity versus registration identity;
- generated CLI/config reference sources and website companion pages;
- `packages/hunk/skills/hunk-extensions/SKILL.md`;
- root and package READMEs if `hunk gh` examples need recovery instructions;
- a user-visible Changeset.

## Test plan

### Unit coverage

- config parsing and layer union;
- ordered CLI overrides;
- pure selection decisions and reason attribution;
- bundled catalog uniqueness and command ownership metadata;
- disabled-command diagnostics;
- load compatibility when selection changes;
- VCS detection with disabled providers;
- list projection and config mutation.

### Integration coverage

- `hunk --disable-extension hunk.gh gh --help` reports disabled;
- config-disabled `hunk.gh` plus CLI enable reaches bundled help;
- disabled explicit user extension never evaluates its module;
- disabled repo extension never prompts for trust;
- enabled `hunk gh` delegation retains and cleans its temporary patch;
- compiled headless disabled-command paths do not extract OpenTUI;
- PTY review remains usable with optional bundled UI extensions disabled.

### Verification

Run the standard relevant gates:

```bash
bun run format:check
bun run lint
bun run typecheck
bun run deps:check
bun run test
bun run test:integration
bun run check:pack
bun run website:check
```

Run the real-TTY smoke suite when bundled UI selection changes.

## Rollout

Ship exact-ID disabling first. Do not add wildcard selectors until the stable bundled identity list
has shipped and real configuration use demonstrates a need. Preserve unknown disabled IDs so a
config can cover an extension that is temporarily absent or installed later; show them as stale in
`hunk extension list --all` rather than silently deleting user intent.

---

### Session Broker Sdk (`docs/session-broker-sdk.md`)

# Session broker SDK contract

This document defines the implementation and migration contract for Hunk's reusable local session
broker SDK. **Must**, **must not**, **should**, and **may** are normative. Current code does not yet
meet every requirement; security and compatibility requirements are release gates.

## Product contract

One daemon serves all live sessions/windows for exactly one immutable application identity. The
daemon owns transport, authentication, runtime validation, routing, correlation, reconnect state,
resource bounds, lifecycle, and diagnostics. The application owns payload schemas, command
semantics, authorization/approval policy, and semantic results. `{appId, sessionId}` is the durable
routing namespace; the daemon must never become a cross-application hub.

### Supported environments

The first public release is ESM-only built JavaScript with declarations and supports Node 22+, Bun
1.3.14+, and non-PTY behavior on macOS, Linux, and Windows. Server processes require WHATWG
`Request`, `Response`, streams, and browser-like WebSocket clients. Browser, edge, Deno,
service-worker, and CommonJS builds are unsupported; runtime entries must not expose TypeScript
source.

The export map orders `types`, `bun`, `node`, then `default`, selecting Bun before its Node-compatible
conditions. `default` throws a clear unsupported-runtime error when neither runtime condition is
asserted. Selected entries validate
their minimum version at startup because export conditions neither enforce versions nor prove
runtime identity.

### Non-goals

The first contract excludes remote operation, TLS/proxy trust, cross-machine discovery, service
installation or a system-wide privileged broker, generic UI dispatch/control discovery or an
approval UI, exactly-once execution, and a generic resource protocol without a second non-Hunk
consumer. Hunk retains review documents,
intents, comments, highlights, navigation, reload, resources/SSE, browser-review capabilities, and
all associated UI semantics.

## Ownership and package topology

Publish one package, `@hunk/session-broker`, with protocol/state, daemon/connection, managed-host,
and Node/Bun adapter boundaries kept as internal modules. Its only first-release entry point exports
the shared primitives, broker APIs, supervision, managed producer API, and an automatically selected
`serveSessionBrokerDaemon`. Hunk composes the package; the package never imports `packages/hunk/src/*`.

Publication requires verified npm `@hunk` scope ownership and trusted publishing. An approved
replacement name must not alter `appId`, wire identity, or runtime namespaces.

### Portable server API

Both conditional entries re-export the same declarations and implement this narrower-than-native
contract:

```ts
interface SessionBrokerDaemonAddress {
  hostname: string;
  port: number;
}
interface RunningSessionBrokerDaemon {
  readonly stopped: Promise<void>;
  address(): SessionBrokerDaemonAddress;
  stop(options?: { force?: boolean; drainTimeoutMs?: number }): Promise<void>;
}
interface ServeSessionBrokerDaemonOptions<Daemon> {
  daemon: Daemon;
  hostname: string;
  port: number;
  handleRequest?: (request: Request) => Response | Promise<Response | undefined> | undefined;
  notFound?: (request: Request) => Response | Promise<Response>;
  formatServeError?: (error: unknown, address: SessionBrokerDaemonAddress) => Error;
}
declare function serveSessionBrokerDaemon<Daemon>(
  options: ServeSessionBrokerDaemonOptions<Daemon>,
): Promise<RunningSessionBrokerDaemon>;
```

Both implementations resolve only after binding, return a non-null portable address, hide native
handles, and make concurrent `stop()` calls share one idempotent completion. Request hooks receive
only a WHATWG `Request`. The same consumer source must typecheck and pass lifecycle tests unchanged
under Node and Bun. Resolution uses export conditions, not a post-import `typeof Bun` branch. The
single-package convenience tradeoff permits Bun installations to contain `ws`, but Bun must never
import or execute it.

Process discovery/launch remains internally separate from protocol/state and the daemon engine.
Applications inject launch commands, environment, compatibility copy, and approval policy.

### Build and release model

The package ships ESM in `dist/` with declarations, conditional exports, README, license, and no
workspace-only imports. It starts at `0.1.0` and reaches `1.0.0` only after all release gates and use
by Hunk plus one clean external reference consumer. During `0.x`, public API breaks require a minor;
after `1.0.0`, a major.

Package semver governs JavaScript/TypeScript APIs. Broker protocol integers govern generic wire
compatibility, and application protocol integers govern app payload/command compatibility. Wire
changes always update explicit ranges and fixtures regardless of package semver.

## Identity model

### Application and session identity

`appId` is immutable at daemon startup, namespaces runtime state, credentials, principals, logs,
negotiation, and sessions, and is never inferred from package, executable, port, path, cwd, or label.
It is 1–128 lowercase ASCII characters:

```text
[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?
```

Reverse-DNS form such as `dev.hunk` is recommended. Every producer, caller, discovery record,
reconnect proof, and capability must match the configured `appId`.

`sessionId` is opaque, unique within `appId`, cryptographically random by default, and stable across
socket reconnects/content reloads. It is 1–128 ASCII bytes:

```text
[A-Za-z0-9](?:[A-Za-z0-9._~-]{0,126}[A-Za-z0-9])?
```

It is never an index, PID, path, or connection ID. Reusing a retired ID requires a new authenticated
registration and cannot revive commands, reconnect capabilities, or idempotency state.

### Connection identity and reconnect rotation

After authentication/negotiation, the daemon issues a `connectionId` binding one socket to an
immutable producer principal and, after registration, `{appId, sessionId}`. Snapshots, heartbeats,
unregisters, cancellation, and results derive authority from that connection. Message-carried IDs
are consistency assertions only and never confer mutation/result authority.

Replacing a connection requires its reconnect capability and follows this idempotent state machine:

1. The producer proves the same app/session authority, creating a new `connectionId`; this atomically
   supersedes the old socket, invalidates its later messages, and resolves old work by the delivery
   matrix unless already terminal.
2. Before register or reconnect, the producer generates and durably stores a replacement private
   key, then sends
   only its public key and a rotation ID.
3. Signed `registered` commits the transport and records the candidate verifier as pending while the
   previous verifier remains valid.
4. Candidate-signed `registration-ack` promotes it and starts a 60-second dual-key overlap. Signed
   `registration-committed` binds the rotation ID and both verifier IDs.
5. After durably recording that response, the producer sends candidate-signed
   `registration-confirmed`; the daemon then revokes the prior verifier.
6. If confirmation never arrives, overlap expiry revokes the old verifier. Recovery tries candidate,
   then old while valid, then audited producer-bootstrap recovery. Bootstrap creates a new transport,
   rejects uncertain work, and cannot silently replay it.

All messages are idempotent for one rotation ID; during overlap, either key returns the same signed
commit state. Only one pending rotation per session is allowed, and an ID can never name a different
candidate.

### Generic registration and snapshot

The generic app registration envelope requires only `sessionId`, app-validated metadata, and an
app-validated initial snapshot; broker-owned reconnect proof/rotation travels outside that envelope.
Snapshot contains display/ordering timestamp `updatedAt` plus app-validated `state`; timestamps grant
no authority. Validation uses the selected application protocol and precedes commit.

PID, cwd, repository root, launch time, terminal/window/pane IDs, and display labels are optional,
untrusted, size-bounded metadata conventions. They never confer authority. Hunk's adapter continues
to require and project its current fields.

## Protocol and compatibility

### Version and feature negotiation

The initial generic broker protocol is revision `1`. Peers advertise inclusive integer `{min, max}`
broker and application ranges. The daemon selects
the highest overlap; invalid/no overlap returns structured `incompatible-protocol` and a
non-retrying policy close without secrets or stacks. Package versions are not wire revisions.

A producer selects one application revision/features during hello; that remains its session
contract until reconnect. A caller records an application range/features without selecting one
globally. For each target request, intersect caller and daemon support with the target session's
selected singleton revision/features and the exact command descriptor/version; use that exact
contract or fail for that target. Target-independent operations use only the selected broker
revision and report each session's application contract. One daemon may host different compatible
session revisions.

Feature IDs are bounded namespaced strings such as `broker.command-cancel.v1`. Only acknowledged
intersection features may affect behavior. Unknown proposals remain unselected. Safely ignorable
additions may be optional; changes to interpretation, authority, delivery, or required state need a
new revision or negotiated feature.

### Authenticated hello and request flow

Producer ordering is:

```text
hello-init -> hello-challenge -> hello-proof -> hello-ack
          -> register -> registered -> registration-ack
caller request -> command -> command-accepted -> command-result -> caller response
```

Authentication and negotiation precede registration. The producer challenge transcript binds the
endpoint, `appId`, daemon generation, full hello proposal, and fresh initiator/responder nonces. The
producer verifies the daemon signature against owner-private discovery state before signing the
same transcript. Signed `hello-ack` additionally binds selected revisions/features and
`connectionId`; registration cannot repeat mutable negotiation claims. Challenges/incomplete
handshakes are single-use, short-lived, rate-limited, and globally bounded.

HTTP callers use the same nonce/challenge/proof/ack exchange on the control route. The signed ack
returns a short-lived generation-bound `callerSessionId`, selected broker contract, recorded caller
application proposal, and initial sequence; it does not preselect a target revision. WebSocket
caller adapters are equivalent.

Each request carries caller session, caller request ID, and a canonical uint64 decimal sequence:
`(?:0|[1-9][0-9]{0,19})`, at most `18446744073709551615`, parsed with integer/BigInt rather than JSON
number. The signature binds generation, caller session, grant/key ID, hello transcript hash, HTTP
method, canonical path and sorted/encoded query, canonical body digest, request ID, and sequence.
Responses bind the same caller session, request ID, and sequence so signatures cannot cross caller
sessions. Authorization precedes execution and cache lookup. Signed target command envelopes and
signed responses repeat the exact selected application revision/features; producers reject mismatches
before parsing input. Target incompatibility returns a structured error without mutating the caller
session.

Sequence `"0"` is reserved and rejected. Caller sessions initialize `highest = 0` with a zero bitmap,
start allocation at `"1"`, and support 32 concurrent out-of-order requests. A 64-bit bitmap bit `d`
records whether `highest - d` was accepted for `0 <= d <= 63`. For a sequence at or
below `highest`, require that range and an unset bit, then set it. For a sequence above `highest`,
require a delta of at most 64, shift the bitmap by that delta (clearing it at 64), discard expired
history, set the new `highest`, and mark bit 0. Thus accepted candidates span `highest - 63` through
`highest + 64`, while the bitmap stores only the 64 replay-relevant accepted values. Reject
duplicates, older values, and larger jumps. Compare before subtracting rather than computing an
overflowing `highest + 64`; clear rather than shift by 64. The check and bitmap/highest update are one
atomic admission transition per caller session. Libraries allocate monotonically before dispatch,
never reuse a sequence after transport failure, and open a new authenticated caller session before
uint64 exhaustion rather than wrapping.

Responses bind broker revision, target application contract when applicable, generation, request
ID, and structured result/error. Callers renegotiate after daemon restart, not per lower-revision
target. Replayed, expired, wrong-generation, or out-of-window requests fail.

No reusable private capability is transmitted. Private keys or signatures may not appear in URLs,
fragments, argv, health/capability responses, logs, or errors. Raw list/get/dispatch remains disabled by default and requires both authentication and
authorization; `exposeHttpApi: true` alone is insufficient.

### Command descriptors

```ts
interface CommandDescriptor {
  name: string;
  version: number;
  title: string;
  description?: string;
  effect: "read" | "write" | "execute";
  idempotency: "none" | "keyed";
  cancellation?: "none" | "best-effort";
  concurrencyGroup?: string;
  requiredFeatures?: readonly string[];
  inputSchemaId?: string;
  resultSchemaId?: string;
  requiredScopes: readonly string[];
}
interface CommandConcurrencyGroup {
  name: string;
  maxActivePerSession: number;
}
```

Each application revision registers descriptors, parsers, and concurrency groups. Group names use
the feature grammar, references must resolve, and capacity is an integer 1–16. `broker.*` is
reserved; omitted group means immutable capacity-one `broker.session-serial`. Per-group queues start
FIFO; different groups have no relative order and cannot bypass session/daemon limits.

Command identity is exact name/version. `title`/`description` are mutable discovery text. Schema IDs
are metadata, not validators. Input/result parsers are keyed by application revision, canonical
feature set, and command/version. Required features must be selected before parsing. Effect informs
but never replaces authorization, approval, or audit policy.

### Rolling compatibility

Binary/package mismatch alone cannot kill a daemon. Compatible peers attach when ranges, required
features, and authentication overlap. Replacement requires an authenticated identity probe,
incompatible required contract, app authorization, exact generation-safe shutdown, and explicit
degradation/reconnect behavior for affected sessions. A PID from health/stale metadata is never enough; compatible
instances remain even across executable versions.

Implementations should retain the prior broker revision for at least one minor compatibility window
when safe. Removal requires golden migration tests and release notes.

### Admin scope

The daemon may expose a revision-tolerant admin scope (`packages/session-broker/src/admin.ts`) with
exactly two actions: `status` (daemon app revision, app version, pid, start time, uptime, and
attached sessions with the app revision each producer presented) and `stop` (graceful shutdown that
first closes attached producers with `"Session daemon restarting."`). It uses the ordinary signed
caller handshake and on-disk credentials against a second authenticator whose fixed contract is
`SESSION_BROKER_ADMIN_SCOPE_VERSION` in place of the app revision, served on its own paths
(`/session-admin/challenge`, `/session-admin/proof`, `/session-admin`). Caller sessions negotiated
there are unknown to the main authenticator and cannot reach the session API. The response schema is
frozen per scope version and is extended only by adding a new version. This is what lets a client
from another app revision explain a refused hello and replace an old daemon without signalling a
pid; a daemon that predates the scope refuses the admin hello, and callers fall back to launch
metadata.

## Runtime validation

Every external boundary receives `unknown`; TypeScript generics and schema IDs are not validators.
Authoritative parsers cover discovery/coordinator/identity responses, every hello/envelope,
credentials and capability claims, registration/snapshot, selectors/deadlines/idempotency, command
input/result for the exact contract, and cross-process health/capabilities. App validator registries are keyed by app and command
revision.

Failures return stable codes without stacks/parser internals, commit no partial mutation or result,
and close producers when stale assumptions would remain. Reject binary WebSocket frames with `1003`
and malformed discriminants, numbers, and versions rather than casting them. Native per-message caps
reject oversized frames before application decoding: Node's `ws` reports `1009`, while Bun 1.3 may
surface its non-configurable abnormal `1006` because it does not invoke the application callback.

## Local security contract

### Threat model and credentials

Supported defenses cover other OS users, DNS rebinding/cross-origin browsers, accidental
non-loopback exposure, local processes without capabilities, and one producer attacking another.
They do not sandbox malware already able to read the same user's files/memory.

The host creates a private per-user/per-`appId` runtime directory: Unix directory `0700`, credential
files `0600`; Windows owner-only ACLs with unsafe ownership/reparse states rejected. Validation fails
closed.

The supervisor creates independent Ed25519 key pairs for coordinator discovery, producer bootstrap,
trusted caller bootstrap, and daemon identity. Private keys stay in owner-only material/authorized
memory. Discovery publishes only key IDs/public verifiers; the daemon stores verifiers and immutable
grants, never replayable secrets.

A grant fixes `appId`, principal ID, key ID/algorithm, optional `sessionId`, allowed operations and
command names/versions, issue/expiry times, revocation ID, and whether narrower delegation is
allowed.
Capabilities require proof of possession. Through a generation-bound signed caller session, a
`capability:issue` holder may submit a locally generated subject public key and requested subset;
the app authorizer must approve it. The daemon never returns the subject private key, which the app
delivers through owner-private storage, inherited descriptor, or app channel. Delegation cannot
broaden scope, lifetime, or session authority.

Bootstrap grants are installed at startup and invalidated by rotation/generation replacement.
Session retirement invalidates reconnect/session grants. Revocation precedes authorization and
cached-result lookup. Nonces, sessions, signatures, grants, and replay windows are bounded/expiring.

Every Ed25519 signature covers a domain-separated RFC 8785 canonical JSON transcript containing the
protocol domain, `appId`, generation, key/grant ID, endpoint or canonical HTTP target, challenge
nonces or caller session/sequence, request ID, negotiated transcript hash/revisions/features, and
SHA-256 body digest as applicable. Golden fixtures define exact transcripts; ambiguous concatenated
strings are forbidden.

### Principals and authorization

```ts
interface ProducerPrincipal {
  appId: string;
  principalId: string;
  scopes: readonly string[];
}
interface CallerPrincipal {
  appId: string;
  principalId: string;
  sessionId?: string;
  operations: readonly string[];
  commands?: readonly string[];
}
```

Authentication proves a grant key; authorization separately decides operation/session/command
access and defaults to deny. Scopes distinguish registration/reconnect, list, snapshot read,
dispatch, command families, diagnostics, and shutdown. `write`/`execute` need explicit scope and app
authorization; apps may add interactive approval. The SDK supplies hooks/cancellation context, not UI.

Audit hooks receive redacted principal/verifier ID, app/session, operation, command/version, request
ID, decision, timestamps, and outcome—not capabilities or full payloads by default.

### Loopback, Host, Origin, and browser review

Adapters refuse non-loopback binds and hostnames resolving to any non-loopback address. One shared
IPv4/IPv6 parser derives exact `(scheme, normalized host, port)` authorities from listeners and
trusted same-origin aliases. Reject missing, malformed, duplicate/comma-joined, ambiguous, or
non-allowlisted `Host` on HTTP and upgrades. Native clients may omit `Origin`; reject `null`,
multiple, non-HTTP(S), or non-allowlisted origins. Emit no permissive CORS by default.

Minimal unauthenticated health exposes liveness only—no PID, counts, paths, snapshots, useful
versions, or capabilities. Detailed diagnostics require admin scope. Generic remote mode and unsafe
remote options are forbidden pending authenticated encryption, certificate/key lifecycle, proxy and
deployment policy, discovery design, and new security review. Hunk may temporarily isolate its
unsupported `HUNK_MCP_UNSAFE_ALLOW_REMOTE` escape hatch.

Hunk browser review keeps independent per-session/per-review capabilities and same-origin/no-CORS
policy. Daemon producer/caller authority must not grant review access.

## Command delivery and backpressure

### Delivery guarantee

The broker makes at most one automatic delivery attempt and never promises exactly-once execution.
Request state is monotonic: `received -> authorized -> queued -> written -> accepted -> completed`,
with rejection/terminal exits. The first terminal transition wins.

| State at terminal cause                       | Outcome                          |
| --------------------------------------------- | -------------------------------- |
| `received`, `authorized`, `queued`            | cause `status` + `not-delivered` |
| `written`, before validated acceptance/result | cause `status` + `unknown`       |
| `accepted`, before terminal result            | cause `status` + `delivered`     |
| validated producer rejection                  | `rejected` + `delivered`         |
| validated success result                      | `completed` + `delivered`        |

Every response separates `status` (`completed`, `rejected`, `timed-out`, `cancelled`,
`disconnected`, or `shutdown`) from `deliveryCertainty` (`not-delivered`, `delivered`, or `unknown`). `delivered` means producer admission, not
side effects. Once writing may have occurred, missing acceptance does not prove non-delivery.
Disconnect, replacement, timeout, cancellation, forced stop, and drain expiry never replay work.
Graceful stop reports queued as `shutdown/not-delivered`, written-unaccepted as `shutdown/unknown`,
and accepted as `shutdown/delivered`.

### Ordering, limits, and accounting

Default scheduling is capacity-one per-session FIFO through `broker.session-serial`; opt-in groups
follow their descriptor capacities and per-group FIFO.

| Resource               | Initial default                                                                                                                                                                          |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Sessions and commands  | 256 sessions/daemon; 64 queued+in-flight/session; 1,024/daemon; 32 queued+executing through one producer bridge; one active/session by default                                           |
| Handshakes             | Per daemon: 64 unauthenticated/challenged sockets, 128 incomplete records, 4 MiB incomplete bytes; 64 KiB/proposal                                                                       |
| Callers and HTTP       | Per daemon: 256 caller sessions at 8 KiB each/2 MiB total, 32 concurrent controls, 64 MiB in-flight body bytes; 4 MiB maximum decoded request; 8 MiB maximum decoded/aggregated response |
| WebSocket              | 8 MiB native inbound message cap; 64 MiB broker-owned delivered-message processing/daemon; 64 socket admissions; buffered outbound: 8 MiB/peer and 64 MiB/daemon                         |
| Retained session state | 4 MiB metadata+snapshot/session; 256 MiB/daemon                                                                                                                                          |
| Command data/time      | 1 MiB validated input/entry; 64 MiB queued-command bytes/daemon; 15 s default timeout; 5 min caller maximum                                                                              |
| Idempotency            | 1,024 entries/session; 65,536 entries/daemon; 10 min TTL; 1 MiB result/entry; 64 MiB/daemon                                                                                              |

Hosts may lower limits. Raising a public network/body/frame/buffer ceiling requires explicit
unsafe-limits configuration and is outside supported defaults. App validators add tighter
collection, string, nesting, state, and command limits.

Overflow returns structured `busy`, `queue-full`, or `capacity-exceeded`. Native WebSocket frame
assembly and runtime-owned queues are outside broker byte accounting; adapters bound them per message
with the native cap and bound peer count at socket admission. Once a message is delivered, reserve its
bytes before broker decode/parse/handling and release in `finally`. Unavailable socket-count admission
returns `503`; unavailable delivered-message capacity or slow-peer outbound overflow closes with
retryable `1013`. Unwritten work is `not-delivered`; written work follows the delivery matrix. Reserve
outbound serialized bytes per peer/daemon until flush/close. HTTP readers reserve the permitted source
maximum before the first pull, resize to actual bytes, and require aggregate capacity for the
source-plus-copy peak before retaining the merged body; unavailable capacity returns `503`.
Unauthenticated overload allocates no handshake state. Response overflow fails before unbounded
aggregation. New-session failure never evicts existing state.

Registration/snapshot replacement and queue/ledger insertion reserve aggregate bytes before commit,
leaving prior state on failure. Byte accounting uses retained UTF-8 bytes plus fixed overhead. The ledger
stores canonical input digests, not bodies; oversized keyed results fail `result-too-large`.
Reclamation may remove expired/terminal LRU entries, never in-flight entries. Full bridge queues fail
the new request when possible rather than retaining it. No overflow silently drops work or grows
memory.

Disconnected sessions lose live registration/snapshot and retain only separately bounded reconnect
authority. Managed producers retain current registration/latest snapshot, coalescing intermediates;
commands are never queued across disconnection.

### Idempotency

`keyed` commands require a key; `none` rejects one as `unexpected-idempotency-key`. Keys use the
session-ID grammar, 1–128 bytes, and scope to `{appId, sessionId, callerPrincipalId,
selectedAppProtocolRevision, selectedFeaturesHash, commandName, commandVersion, key}`. Feature hash
is SHA-256 over sorted selected IDs; content digest is SHA-256 over RFC 8785 canonical validated
input.

Authorization/approval precedes ledger lookup. Same scope/key/digest returns a terminal result or
joins in-flight work; another digest is `idempotency-conflict`. Only the originating caller
principal or an explicit administrative capability may cancel joined work. The daemon owns one bounded in-memory TTL ledger; host libraries own key/retry policy,
not cached results. Restarts lose dedupe evidence, so retries remain app-owned with unknown delivery
unless durable app evidence exists.

### Cancellation and timeout

Cancellation is negotiated, best effort, and limited to the originating caller principal or an
explicit administrative capability. Advertised commands get
`AbortSignal` and may receive wire `cancel`; broker cancellation does not prove rollback. Late results
are audited/ignored and never resolve newer requests. Runtime-validated deadlines are bounded; local
scheduling uses an injected monotonic clock where possible, while wire deadlines remain explicit
timestamps with documented skew limits.

## Session and daemon lifecycle

### Heartbeats, idle, and stop

Heartbeat interval is 10 seconds, stale TTL 45 seconds, and sweep interval 15 seconds. A maintenance
gap beyond stale TTL indicates suspension: set `recoveryUntil = now + staleSessionTtlMs`; no health,
manual, or scheduled pruning may occur before it, and later observations cannot shorten it. Sessions
must heartbeat during recovery or may be removed on the first eligible sweep. Removal rejects
pending commands with an explicit stale/disconnected outcome and follows reconnect-retirement policy.

Default idle timeout is 60 seconds and may be disabled. Idle starts only with no sessions, queued or
pending commands, authenticated requests still being handled, active streams, or configured grace
work. Minimal
health does not reset idle; authorized control may. The daemon publishes `stopping` before teardown.

Graceful stop atomically refuses new work; resolves queued/unwritten as `shutdown/not-delivered`;
asks producers to close/reconnect when negotiated; drains in-flight work for five seconds by default;
then resolves it by the delivery matrix, closes WebSockets/listener, releases generation-owned
state, and resolves `stopped` once. Forced stop skips drain, immediately rejects work, and closes
active transports. Repeated/concurrent stops share the same completion across Node/Bun.

## Discovery, singleton launch, and ownership

The target uses an owner-private per-application coordinator and random broker endpoint. Because
this changes Hunk's fixed default port, Phase 4 must stop for explicit approval; denial requires a
revised fixed-endpoint contract with collision/foreign-listener tradeoffs.

### Election and takeover

Runtime paths derive from encoded/digested stable `appId`, never display name, port, executable, or
package. A candidate daemon—not its launching app—owns election and retains the winning coordinator
socket:

1. Bind a random loopback coordinator port; create generation ID, daemon key, PID+start-token
   fingerprint, and initialization deadline.
2. Atomically create-if-absent a complete owner-private `initializing` record naming that bound
   candidate/public key. Use complete temp inode plus no-replace (or equivalent Windows primitive)
   and directory sync where supported.
3. A loser closes and exits; hosts reread/wait for authenticated readiness.
4. A winner starts the random broker endpoint, verifies the record still names its exact generation,
   publishes `ready` atomically, and retains the coordinator bind.

The record is rendezvous metadata; retained exclusive bind plus authenticated challenge proves live
authority. PID/endpoint metadata alone never authorizes signaling.

Takeover requires the elapsed initialization deadline (or ready-endpoint failure window equal to the
startup timeout), no authenticated coordinator response, and proof the recorded PID+start token is
dead/reused. Live, hung, sleeping, inaccessible,
or indeterminate owners are conflicts. A contender takes an owner-private exclusive lock, rereads,
and requires unchanged generation/content digest before atomically retiring the record and
publishing a new generation. Losers close/release
and reread. Old candidates revalidate generation before ready and every handshake; after ownership
change they exit without touching new state. Cleanup requires the lock and matching generation/digest.

Coordinator discovery returns `appId`, generation, broker endpoint, broker range/discovery features,
and ready/stopping state—never private keys. Clients verify fresh identity/generation before an
authenticated session; generation/transcript-bound signatures prevent stale/foreign credential
collection. Foreign listeners, unsafe ownership, malformed records, wrong app, or failed challenge
produce fail-closed conflicts, never termination.

### Launch and shutdown ownership

The host injects app identity, runtime base, executable/args, environment allowlist, identity and
compatibility probes, clock/sleep/spawn/PID effects, deadlines, and app error formatting. Concurrent
hosts may spawn candidates, but all use the authenticated winner. Cleanup/termination must prove
exact `appId` and generation; PID is diagnostic only. Endpoint overrides remain in the same
coordinator namespace and cannot create a second daemon.

## Selectors and metadata

`SessionSelector` has optional `sessionId`. The default resolver selects it exactly; omission succeeds
only with one session, otherwise returns no-session/ambiguity. Apps may inject a validated,
deterministic, non-mutating resolver over read-only session views, returning one ID or structured
no-match/ambiguity.

The optional developer-tools plugin preserves precedence `sessionId`, `sessionPath` matching exact
live `cwd`,
nearest containing `repoRoot` respecting optional `repoBoundary`, then sole-session fallback.
Project-boundary/VCS discovery remains app-owned; the SDK knows no `.git`, `.hunk`, provider, or
extension semantics. Selector metadata remains untrusted, bounded, and non-authoritative.

## Hunk composition and migration

Hunk owns its session schemas/projections/errors; review documents/publication generations,
resources/cache/assembly, comments/highlights/navigation/reload/intents; HTTP actions lowering to
`createHunkSessionBridge`; browser routes/SSE/capabilities/digest verification; VCS boundary
discovery; and executable launch/upgrade copy. Generic state removes Hunk-shaped projections; Hunk
builds them from read-only generic entries/events, and the package never imports Hunk types.

| Existing contract                                    | Migration requirement                                                                                                                                                 |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HUNK_MCP_HOST`, `HUNK_MCP_PORT`, `HUNK_MCP_DISABLE` | Preserve in Hunk adapter for at least one documented minor window.                                                                                                    |
| `HUNK_MCP_UNSAFE_ALLOW_REMOTE`                       | Temporary unsupported Hunk-only escape hatch; never generic.                                                                                                          |
| runtime directory `hunk-mcp`                         | Preserve or dual-read so old/new binaries cannot race separate namespaces.                                                                                            |
| default `127.0.0.1:47657`                            | Winning candidate reserves/retains it as guard before coordinator publication; explicit port reserves that endpoint in the same namespace.                            |
| `/session`, `/session-api`                           | Preserve CLI semantics but require new authentication; old clients get actionable upgrade refusal.                                                                    |
| `/mcp` returns `410`                                 | Retain until separately deprecated.                                                                                                                                   |
| incompatible daemon                                  | Never signal from unverifiable PID. Interactive Hunk waits and retries until the incumbent exits while idle; forced replacement requires an authenticated generation. |
| missing `repoBoundary`                               | Preserve containment fallback; new clients may provide VCS-aware boundary.                                                                                            |

Security outranks wire compatibility: no migration accepts unauthenticated control/registration.
An interactive Hunk window keeps reviewing locally while an incompatible or pre-authentication
incumbent owns the endpoint. After the first signed-handshake refusal it polls only minimal health,
so repeated WebSocket closes cannot postpone the incumbent's idle timeout; once health disappears,
the same bounded connection object reruns discovery, signed negotiation, and registration. One-shot
session commands fail promptly with instructions instead
of becoming a second restart owner. Daemons too old to retire while idle, or hung incumbents, still
require manual termination. Preservation means paths, selectors, outputs, and automatic credential
discovery for upgraded clients—not interoperability with pre-authentication binaries.

Hunk's fixed-endpoint Phase-1 credential store uses a home-local `.hunk` parent when
`XDG_RUNTIME_DIR` is unavailable, rather than a predictable name in a shared temporary directory.
It inherits the current user's ACL when it creates the `hunk-mcp/security-v1` directory
on Windows and rejects symbolic-link redirection. Node does not
provide a portable owner/DACL or general reparse-point inspection API, so this integration cannot
detect a pre-existing custom permissive DACL or every non-symlink reparse point; completing native
Windows ACL validation remains a release-gate item before the reusable package is published.

The fixed-endpoint integration authenticates bootstrap reconnects, distinguishes `register` from
`reconnect` scope, atomically retires the previous socket, and rejects its uncertain work. Retained
producer authority is rechecked before inbound mutations and before any queued command bytes leave
the daemon. It does
not yet claim the durable candidate-key `registered`/`registration-ack` rotation sequence above;
that sequence remains a publication gate rather than an unauthenticated compatibility fallback.

Before publishing even `initializing`, a Hunk candidate binds and retains the legacy guard endpoint;
only its holder may enter coordinator election. A contender unable to bind waits a bounded startup
interval for authenticated coordinator publication, then reports an unverifiable listener and launches
nothing. The winner rolls back/exits if guard retention fails and cannot become ready without it.
The guard provides upgrade/auth refusal and routes supported upgraded traffic. Explicit
`HUNK_MCP_PORT` becomes the actual reserved endpoint, not a second random endpoint. Unverifiable
incumbents are never signaled. Generic discovery never publishes the browser-review secret. Before
changing defaults, Phase 4 tests concurrent candidates, guard-before-publication, rollback, explicit
ports, and mixed old/new failures.

## Release gates

No public release may proceed while any gate fails; unresolved critical/high security findings block.

### Security gate

- Independent review covers ownership, capabilities, Host/Origin, discovery permissions, shutdown,
  and leakage. Adversarial tests prove socket B cannot supersede, snapshot, heartbeat, unregister,
  cancel, or answer for socket A without authenticated reconnect, and superseded sockets cannot
  mutate or resolve later work.
- Missing, malformed, wrong, expired, rotated, and revoked credentials fail without leaks; list, get,
  dispatch, command, and admin scopes are isolated; raw HTTP requires authentication+authorization.
- Browser-review authority remains narrow; secrets never enter URLs, logs, health/capabilities,
  errors, bodies, argv, or non-credential metadata.
- Node/Bun share parser fuzz/property and binary/frame/body/queue/buffer limits.
- Owner permissions, symlink/reparse handling, and atomic publication pass Linux, macOS, and real
  Windows; supported configuration refuses remote binds.

### Compatibility gate

- Hand-authored old/new producer/daemon/caller matrices and golden fixtures cover highest overlap,
  structured no-overlap, every revision/feature transition, and descriptor/validator agreement;
  unknown or unselected features cannot change behavior.
- Hunk env/path/guard/selectors/routes, old-client refusal, and mixed-version fail-closed flows remain
  tested.
- Replacement proves app identity/exact generation; documentation separates wire and package versions.

### Runtime and packaging gate

- The tarball contains built ESM, declarations, README, license, and intended exports only—no source,
  tests, credentials, runtime artifacts, local files, or unresolved `workspace:*`.
- Fresh external projects install that tarball and complete
  register/list/get/dispatch/result/reconnect/shutdown on Node 22 and Bun 1.3.14; Node tests use no
  Bun globals or `bun:test`.
- NodeNext and bundler consumers compile without repo aliases. Conditional entries share public API,
  lifecycle, and transport suites; Bun selects its entry and never loads `ws`.
- Changesets/publish dry-run succeeds; npm scope ownership, provenance, and trusted publishing are verified.

## Phased implementation

| Phase | Required outcome                                                                                                                                                                                                                                         |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1     | Fix connection ownership, signed producer/caller authentication, fixed Hunk `appId`, broker revision `1`, current Hunk app revision as a singleton, empty generic features, authorization, validation, and bounds without an unauthenticated transition. |
| 2     | Achieve Node/Bun frame, size, shutdown, and machine-sleep parity.                                                                                                                                                                                        |
| 3     | Generalize identity/ranges/features/descriptors/registration/selectors while preserving Hunk's developer-tools plugin; prove Phase 1 is its one-element wire case.                                                                                       |
| 4     | After explicit approval, implement private-credential coordinator discovery, guard migration, supervision, and generation-safe ownership.                                                                                                                |
| 5     | Compose supervision, negotiation, connection, reconnect/rediscovery, registration/snapshots, warnings, and ordered shutdown into managed host API.                                                                                                       |
| 6     | Build, pack, install, test, and verify npm scope/trusted publication; do not publish without explicit approval.                                                                                                                                          |
| 7     | Adopt in Hunk, remove obsolete paths, preserve semantics, run end-to-end flows, reconcile historical TODOs, and complete independent review.                                                                                                             |

---

--- METRICS ---
- Files Extracted: 60
- Estimated Token Budget: ~134833 tokens
- Canonical Reference: https://codewiki.google/github.com/modem-dev/hunk
