diff --git a/docs/tools/exit_plan_mode.md b/docs/tools/exit_plan_mode.md
deleted file mode 100644
index 769d69106..000000000
--- a/docs/tools/exit_plan_mode.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# exit_plan_mode
-
-> Submits the current plan-mode plan for user approval.
-
-## Source
-- Entry: `packages/coding-agent/src/tools/exit-plan-mode.ts`
-- Model-facing prompt: `packages/coding-agent/src/prompts/tools/exit-plan-mode.md`
-- Key collaborators:
- - `packages/coding-agent/src/tools/plan-mode-guard.ts` — resolves canonical plan paths during plan mode
- - `packages/coding-agent/src/plan-mode/approved-plan.ts` — renames approved plan artifact after user approval
- - `packages/coding-agent/src/modes/interactive-mode.ts` — approval popup, plan preview, mode exit, tool restoration
- - `packages/coding-agent/src/plan-mode/state.ts` — plan-mode state shape
-
-## Inputs
-
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `title` | `string` | Yes | Final plan title. `.md` is optional; the runtime normalizes to `local://
.md`. Allowed characters: letters, numbers, `_`, `-`. |
-
-## Outputs
-- Single-shot success result with `content[0].text = "Plan ready for approval."`.
-- `details` contains:
- - `planFilePath` — current plan artifact path from plan-mode state, typically `local://PLAN.md`
- - `planExists` — whether that file existed at call time
- - `title` — normalized title without `.md`
- - `finalPlanFilePath` — normalized destination, always `local://.md`
-- The actual rename and mode transition happen later in the interactive controller after the user chooses an approval action.
-
-## Flow
-1. `execute()` reads `session.getPlanModeState()` and rejects the call unless `state.enabled` is true.
-2. `normalizePlanTitle()` trims whitespace, rejects empty values, rejects `/`, `\\`, and `..`, appends `.md` if missing, and enforces `^[A-Za-z0-9_-]+\.md$`.
-3. The tool computes `finalPlanFilePath = local://.md` and resolves both source and destination through `resolvePlanPath(...)` to validate them against plan-mode path rules.
-4. It `stat`s the current plan file path; if the plan artifact does not exist it throws a `ToolError` telling the caller to write the finalized plan first.
-5. On success it returns the approval-ready payload; it does not mutate files itself.
-6. `packages/coding-agent/src/modes/controllers/event-controller.ts` watches successful `exit_plan_mode` results and forwards `details` to `InteractiveMode.handleExitPlanModeTool(...)`.
-7. The interactive controller aborts the agent, renders the current plan, and shows four choices: `Approve and execute`, `Approve and keep context`, `Refine plan`, `Stay in plan mode`.
-8. If the user approves, `#approvePlan(...)` renames `local://PLAN.md` to `local://.md`, exits plan mode, restores the previous tool set, optionally clears session context, writes the approved plan into the new local root when context is reset, and injects a synthetic system prompt instructing execution from the finalized artifact.
-
-## Side Effects
-- Filesystem
- - Tool itself only `stat`s the current plan file.
- - Approval path later renames the plan artifact via `fs.rename(...)` and may rewrite the approved plan into a fresh local root with `Bun.write(...)`.
-- Session state
- - Requires active plan-mode state.
- - Approval flow aborts the current agent loop, exits plan mode, restores previous active tools, clears or preserves context depending on the user choice, and records the approved plan reference path.
-- User-visible prompts / interactive UI
- - Successful calls trigger a plan preview and an approval/refinement selector in interactive mode.
-- Background work / cancellation
- - The controller aborts the running agent before showing the popup to prevent repeated `exit_plan_mode` calls.
-
-## Limits & Caps
-- `title` accepts only `[A-Za-z0-9_-]` plus optional `.md` (`packages/coding-agent/src/tools/exit-plan-mode.ts`).
-- Destination must be under the `local:` scheme; approval rename rejects non-`local:` source or destination paths (`packages/coding-agent/src/plan-mode/approved-plan.ts`).
-- In plan mode, only the plan file may be edited; other writes are blocked by `enforcePlanModeWrite(...)` in `packages/coding-agent/src/tools/plan-mode-guard.ts`.
-
-## Errors
-- Plan mode inactive: throws `ToolError("Plan mode is not active.")`.
-- Empty title: throws `ToolError("Title is required and must not be empty.")`.
-- Path traversal / separators: throws `ToolError("Title must not contain path separators or '..'.")`.
-- Invalid characters: throws `ToolError("Title may only contain letters, numbers, underscores, or hyphens.")`.
-- Missing plan artifact: throws `ToolError("Plan file not found at ... Write the finalized plan ... before calling exit_plan_mode.")`.
-- Approval-time failures surface in the UI from `InteractiveMode.handleExitPlanModeTool(...)`, including destination already exists and rename failures from `renameApprovedPlanFile(...)`.
-
-## Notes
-- This tool is hidden/internal: it is injected when `plan.enabled` is on and is not part of normal discoverable built-ins (`packages/coding-agent/src/tools/index.ts`, `packages/coding-agent/src/session/agent-session.ts`).
-- The tool returning success does not mean plan mode has ended; it only means the request was handed off to the approval UI.
-- `resolvePlanPath(...)` special-cases bare filenames matching the plan basename so `PLAN.md` maps back to the canonical session-scoped `local://PLAN.md` artifact.
-- `Approve and keep context` skips the full conversation reset; `Approve and execute` clears context, then copies the approved plan into the new session-local artifact root before execution resumes.
diff --git a/docs/tools/search_tool_bm25.md b/docs/tools/search_tool_bm25.md
index 886f446bb..c456aa041 100644
--- a/docs/tools/search_tool_bm25.md
+++ b/docs/tools/search_tool_bm25.md
@@ -110,7 +110,7 @@
- Corpus composition is session-dependent and excludes already-active tools:
- MCP entries come from `#discoverableMCPTools`, filtered to names not currently active, mapped with `summary = description`.
- Built-in entries appear only in `"all"` mode and only for registry tools whose `loadMode === "discoverable"` and are not currently active.
- - Hidden/internal built-ins are intentionally excluded from the built-in corpus: `resolve`, `yield`, `exit_plan_mode`, `report_finding`, `report_tool_issue` are called out in the `#collectDiscoverableBuiltinTools()` comment.
+ - Hidden/internal built-ins are intentionally excluded from the built-in corpus: `resolve`, `yield`, `report_finding`, `report_tool_issue` are called out in the `#collectDiscoverableBuiltinTools()` comment.
- `DiscoverableToolSource` includes `"extension"` and `"custom"`, but `AgentSession.getDiscoverableTools()` currently assembles only built-in and MCP sources.
- On startup, `packages/coding-agent/src/sdk.ts` hides non-essential discoverable built-ins in `tools.discoveryMode = "all"`; defaults are `read`, `bash`, and `edit` unless `tools.essentialOverride` changes them.
- Query tokenization is simple and deterministic: camelCase is split, non-alphanumerics become spaces, tokens are lowercased, and only non-empty alphanumeric tokens survive.
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index e329b5e80..51ffce1f5 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -1,24 +1,23 @@
# Changelog
## [Unreleased]
+### Breaking Changes
-### Fixed
+- Removed the dedicated `exit_plan_mode` tool and its prompt, requiring plan-mode completion to use the existing `resolve` tool path instead
-- Queued `/skill: [args]` invocations now show as compact `Steer: /skill: [args]` / `Follow-up: /skill: [args]` chips in the pending-messages bar and disappear when the agent consumes the queued message (parity with plain-text steer/follow-up). Previously the queued skill was invisible while queued and rendered as a full skill block at consumption with no chip ever appearing.
-- Plan-mode "Approve and compact context" no longer surfaces a red "Operation aborted" line on the plan-mode assistant message; the silent transition into compaction now renders cleanly on both live and replay paths. Real user-cancel aborts on unrelated turns and the existing "Compaction cancelled" path are unchanged.
-- Auto-recover conflict-resolution `write`/`read` paths that the agent malformed as `:conflict://` (or `:conflict://*`) by mixing the `:conflicts` read selector with the `conflict://` scheme. The stripped `:` prefix is stored on `ParsedConflictUri.recoveredPrefix` and, for writes, surfaces as a trailing note in the result text so the agent learns the correct shape. Clean `conflict://…` URIs are unchanged.
-- Fixed hashline edit renderer leaving a stray `@` in the displayed file path when the agent emitted a canonical `@@ PATH` header (or any `@`-run longer than one). Titles like `Edit: @ packages/foo.ts` now render as `Edit: packages/foo.ts`, matching the actual parser in `hashline/input.ts` which already strips every leading `@` before resolving the path. Purely cosmetic — the edit itself was always routed to the correct file.
### Added
+- Added optional `extra` metadata object to the `resolve` tool so callers can pass context-specific payloads, including plan approval titles
- Added `hide: true` frontmatter option for skill `SKILL.md` files. Hidden skills are still loaded and remain reachable via `skill://` URLs and (when enabled) `/skill:` slash commands, but are omitted from the rendered system prompt's `` listing so the model won't auto-discover them. Use for skills the user opts into explicitly rather than ones the model should pick up from descriptions.
- Added middle elision for streaming tool outputs (bash, ssh, python, js eval) and post-execution tool result spill. When `tools.artifactHeadBytes` is set (default 20 KB), large outputs now keep both the first N KB and the last N KB with an inline `[… N lines elided (M KB) …]` marker between them, instead of dropping everything before the trailing tail. Setting `tools.artifactHeadBytes = 0` reverts to the previous tail-only behavior. The full output is still mirrored to the session artifact (`artifact://`) regardless of elision mode. Exposes `truncateMiddle` and `formatMiddleElisionMarker` from `@oh-my-pi/pi-coding-agent/session/streaming-output`, extends `OutputSinkOptions` with `headBytes`, and adds `direction: "middle"` plus `headRange` / `tailRange` / `elidedLines` / `elidedBytes` to `TruncationMeta`.
- Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
- Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**.
-
- Added per-subagent cost display (`$X.XX` in the task progress tree and the session-observer stats line). Cost is accumulated incrementally from `message_end` events and shown only when non-zero, using the `statusLineCost` theme color. Providers that do not report per-turn cost data (e.g. subscription/OAuth usage) continue to show nothing.
### Changed
+- Changed plan-mode completion to use `resolve { action: "apply", reason, extra: { title } }` to request plan approval rather than calling `exit_plan_mode`
+- Changed resolve pending-action previews to trim and truncate long `reason` text for cleaner status-line rendering
- Raised the image downscaling default JPEG quality from 75 to 80 in `resizeImage` output generation
- Changed image resize metadata notes from coordinate-scale hints to a simple `Image resized from to ` message and hide the note when the resized dimensions are unchanged
- Removed `utils/image-convert.ts` and its `convertToPng` helper; callers now inline `new Bun.Image(bytes).png().toBase64()` from [`Bun.Image`](https://bun.com/docs/runtime/image) (Bun 1.3.14+).
@@ -28,9 +27,12 @@
- Changed search truncation metadata/renderer output from match/result-based limits to file-based limits (`fileLimitReached`, `perFileLimitReached`) and updated truncation labels accordingly
- Lowered `read.defaultLimit` default from `500` to `300` lines, and split the per-range context padding into asymmetric `RANGE_LEADING_CONTEXT_LINES = 1` / `RANGE_TRAILING_CONTEXT_LINES = 3` (was symmetric `RANGE_CONTEXT_LINES = 3`). Replay analysis over post-summarizer sessions (`scripts/session-stats/optimize_read_config.py`) showed that bare-path reads are over-provisioned at the median (file p50 = 220 lines) and that most follow-up reads are disjoint hops rather than adjacent extensions — so a smaller default plus narrower leading context reclaims tokens without measurably changing first-cover rate. Trailing context stays at 3 lines to keep anchor-stale recovery on narrow reads. Explicit `read.defaultLimit` overrides in settings are honoured unchanged.
-
### Fixed
+- Queued `/skill: [args]` invocations now show as compact `Steer: /skill: [args]` / `Follow-up: /skill: [args]` chips in the pending-messages bar and disappear when the agent consumes the queued message (parity with plain-text steer/follow-up). Previously the queued skill was invisible while queued and rendered as a full skill block at consumption with no chip ever appearing.
+- Plan-mode "Approve and compact context" no longer surfaces a red "Operation aborted" line on the plan-mode assistant message; the silent transition into compaction now renders cleanly on both live and replay paths. Real user-cancel aborts on unrelated turns and the existing "Compaction cancelled" path are unchanged.
+- Auto-recover conflict-resolution `write`/`read` paths that the agent malformed as `:conflict://` (or `:conflict://*`) by mixing the `:conflicts` read selector with the `conflict://` scheme. The stripped `:` prefix is stored on `ParsedConflictUri.recoveredPrefix` and, for writes, surfaces as a trailing note in the result text so the agent learns the correct shape. Clean `conflict://…` URIs are unchanged.
+- Fixed hashline edit renderer leaving a stray `@` in the displayed file path when the agent emitted a canonical `@@ PATH` header (or any `@`-run longer than one). Titles like `Edit: @ packages/foo.ts` now render as `Edit: packages/foo.ts`, matching the actual parser in `hashline/input.ts` which already strips every leading `@` before resolving the path. Purely cosmetic — the edit itself was always routed to the correct file.
- Fixed model contextWindow and maxTokens defaulting to `UNK_CONTEXT_WINDOW` (222222) / `UNK_MAX_TOKENS` (8888) when cached or freshly-discovered provider models replace bundled models through `ModelRegistry.#mergeResolvedModels`. The merge now preserves the bundled model's values when the replacement only has sentinel fallbacks.
- Fixed headless `browser.open` tab startup on slow Chromium target enumeration by making worker-side stealth user-agent target setup selective, bounded, and best-effort for non-active targets. Worker startup errors are now surfaced directly instead of degrading into the generic tab worker initialization timeout.
- Fixed token display for sessions and subagents inflating far beyond the context window. `token_total` status-line segment and the subagent overlay token counter now show `input + output + cacheWrite` instead of `input + output + cacheRead + cacheWrite`. With prompt caching, `cacheRead` per turn equals the full cached context — summing it across all turns produces a cumulative total that is N×context_size (e.g. a 5-turn session with a 1 M-token context reported ~5 M tokens). Cache activity is still visible via the dedicated `cache_read`/`cache_write` status-line segments; billing cost is unaffected.
diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md
index 9530de175..f86a7e7ff 100644
--- a/packages/coding-agent/DEVELOPMENT.md
+++ b/packages/coding-agent/DEVELOPMENT.md
@@ -389,7 +389,7 @@ A `ToolFactory` is `(session: ToolSession) => Tool | null | Promise
`createTools(session, toolNames?)` is the entry point. It:
-1. Normalizes requested tool names (`toolNames`) and injects `exit_plan_mode` while `plan.enabled` is true.
+1. Normalizes requested tool names (`toolNames`).
2. Resolves eval backend allowance via `PI_PY` override (`getEvalBackendsFromEnv()`) or `eval.py` / `eval.js` settings.
3. Performs Python kernel preflight when applicable (`checkPythonKernelAvailability`).
4. Computes effective gating (`isToolAllowed`) from settings and runtime state:
@@ -397,7 +397,7 @@ A `ToolFactory` is `(session: ToolSession) => Tool | null | Promise
- recursion guard for `task` (`task.maxRecursionDepth` vs `session.taskDepth`)
- yield mode (`requireYieldTool`) and `todo_write` suppression
5. Instantiates selected tools in parallel with `Promise.all`, records slow factory timings when `PI_TIMING=1`, and wraps results with `wrapToolWithMetaNotice`.
-6. Includes `resolve` only when at least one instantiated tool has `deferrable: true` (deferred preview/apply workflows).
+6. Includes `resolve` unconditionally so plan mode and deferred preview/apply workflows always have it available.
The wrapper step is not cosmetic: it enforces uniform meta-notice behavior and normalized error rendering across all tools.
@@ -1129,15 +1129,14 @@ Primary file: `packages/coding-agent/src/tools/index.ts`.
- `export const BUILTIN_TOOLS: Record = { ... }`
- Key is the external tool name (e.g. `"read"`, `"web_search"`).
4. If it should be hidden/system-only, register under `HIDDEN_TOOLS` instead.
- - Existing hidden names: `yield`, `report_finding`, `exit_plan_mode`, `resolve`.
+ - Existing hidden names: `yield`, `report_finding`, `resolve`.
5. Wire feature gates in `isToolAllowed(name)` when the tool needs runtime enable/disable behavior.
- Existing gates use `session.settings.get(".enabled")` and recursion limits for `task`.
6. If the tool should be selectable by type, update `ToolName = keyof typeof BUILTIN_TOOLS` consumers as needed.
Notes from current behavior:
-- `createTools()` injects `exit_plan_mode` when `toolNames` are specified and `plan.enabled` is true.
-- `resolve` is included only when at least one active tool is marked `deferrable: true` (built-in or extension/custom).
+- `createTools()` always includes `resolve`. Plan mode uses it (via the agent calling `resolve` with `extra: { title }`) to submit a finalized plan for user approval; preview/apply tools (e.g. `ast_edit`) use it to gate apply/discard.
- `yield` is force-added when `session.requireYieldTool === true`.
- Eval availability is mode-driven (`PI_PY`, `eval.py`, `eval.js`); eval falls back to JavaScript when Python is unavailable and JavaScript is enabled. The standalone `bash` tool is always available.
diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts
index 954a176b0..a0479e12e 100644
--- a/packages/coding-agent/src/export/html/template.generated.ts
+++ b/packages/coding-agent/src/export/html/template.generated.ts
@@ -1,2 +1,2 @@
// Auto-generated by scripts/generate-template.ts - DO NOT EDIT
-export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n \n