Commit Diff


commit - 8d819406d7a0b6260ad8fd9433562a701de987e8
commit + babda3eaa7c8cc9a67a59ab6e6de39a31910713f
blob - /dev/null
blob + 04fa135dd892025bb9daaae87c151492d232f94f (mode 644)
--- /dev/null
+++ .scratch/context-compaction/issues/01-serialize-conversation-for-summarization.md
@@ -0,0 +1,83 @@
+# Serialize conversation history for summarization
+
+Status: needs-triage
+Type: task
+
+## What to build
+
+Change `/compact` so the summarizer receives one explicitly delimited text
+document instead of the original messages in their live User/Assistant roles.
+Render the canonical conversation with labels such as:
+
+```text
+[User]: What the Owner said
+
+[Assistant]: Response text
+
+[Assistant tool calls]: read(path="foo.c")
+
+[Tool result]: Tool output
+```
+
+Wrap that serialization in `<conversation>` tags and use a summarizer-specific
+system instruction that says to summarize, never answer or continue the
+embedded conversation. Tool results are capped at 2,000 bytes each and retain
+their beginning followed by a conspicuous count of omitted bytes.
+
+This adapts pi's
+[`serializeConversation()`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/compaction/utils.ts)
+and summarization request shape rather than copying TypeScript into fugu. The
+current fugu message model has no thinking block, so it does not emit pi's
+`[Assistant thinking]` record unless that becomes canonical conversation state
+in a separate change.
+
+## Why
+
+Today `cmd_compact()` passes the active Projection as ordinary provider
+messages and appends the summarization instruction as one more User message.
+The model can interpret that as a conversation to continue. The raw tool
+results also make the compaction request nearly as vulnerable to context
+overflow as the request it is meant to rescue.
+
+## Acceptance criteria
+
+- [ ] A pure, length-aware serializer handles User text, Assistant text,
+  Assistant tool calls, and User tool results in Projection order.
+- [ ] Labels and blank-line separation are byte-exact and covered by unit
+  tests; embedded role-looking text remains data inside the delimited document.
+- [ ] Each tool result keeps at most 2,000 bytes, never splits valid UTF-8, and
+  appends an omission marker containing the exact omitted-byte count.
+- [ ] Tool names and JSON arguments are rendered deterministically and remain
+  bounded by the existing Turn/request limits.
+- [ ] `/compact` sends exactly one serialized User document under a
+  summarizer-specific system prompt, with no tools and no live conversation
+  roles preceding it.
+- [ ] The generated summary still becomes the system-block continuation notes,
+  is journaled, resumes faithfully, and leaves manual `/compact` UI behavior
+  unchanged.
+- [ ] Adversarial tests cover control bytes, malformed UTF-8, empty blocks,
+  1,999/2,000/2,001-byte results, multiple tools, and the request-size bound.
+- [ ] The compaction contract is documented in `docs/compaction.md` (creating
+  the document if this is the first compaction ticket implemented).
+
+## Code-cost analysis
+
+Expected size: **small-to-medium, about 350-600 lines including tests and
+documentation, across 3-5 focused commits**.
+
+| Seam | Likely work | Cost/risk |
+| --- | --- | --- |
+| Serializer | New `compaction.c/.h` over `struct msg`/`struct block`, UTF-8-safe truncation, deterministic tool formatting | 140-220 production lines; medium I13 risk |
+| Request shape | Let `cmd_compact()` supply a summarizer system prompt and one serialized message instead of the live Projection | 60-120 production lines; medium provider-parity risk |
+| Unit and wire tests | New pure suite plus `regress/turn` assertions for exact Anthropic/OpenAI/Claude Subscription bodies | 140-230 test lines |
+| Docs/build | Makefile registration and compaction documentation | 30-60 lines |
+
+The main uncertainty is whether “2 KB” means pi's exact 2,000-character
+JavaScript cap or a 2,048-byte fugu cap. This ticket recommends a named
+2,000-byte cap so the bound is deterministic in fugu's byte-oriented APIs.
+
+## Blocked by
+
+None - can start immediately.
+
+## Comments
blob - /dev/null
blob + 7a534471a6b428bfe98854a4d770d66043727fd5 (mode 644)
--- /dev/null
+++ .scratch/context-compaction/issues/02-cumulative-file-tracking-in-summaries.md
@@ -0,0 +1,83 @@
+# Carry cumulative file tracking through compaction summaries
+
+Status: needs-triage
+Type: task
+
+## What to build
+
+Every fugu-generated compaction summary carries deterministic
+`<read-files>` and `<modified-files>` blocks. Collect paths from structured
+`read`, `write`, `edit`, and `multi_edit` tool calls in the span being
+summarized, merge them with the previous fugu compaction's structured file
+details, sort and deduplicate them, and append the blocks to the model-written
+summary before it becomes continuation notes in the system block.
+
+This follows pi's cumulative file-operation design in
+[`compaction.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts)
+and
+[`utils.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/compaction/utils.ts):
+modified paths win over read-only paths, and prior structured details—not the
+model's prose—are the accumulation source.
+
+## Why
+
+The information is cheap to derive from tool calls, survives lossy prose
+summarization, and is directly useful to the same model when the summary is
+injected into fugu's system block. Without cumulative state, a second
+compaction can forget files that were important before the first cut.
+
+## Acceptance criteria
+
+- [ ] A length-aware collector extracts `path` only from the supported
+  structured file-tool arguments; shell command text is never guessed as a
+  path.
+- [ ] Read-only paths appear under `<read-files>` and any path written or
+  edited appears only under `<modified-files>`, regardless of operation order.
+- [ ] Lists are bytewise deterministic, sorted, and deduplicated across the
+  current span and every prior fugu-generated compaction.
+- [ ] The final XML-like blocks are generated by fugu and appended once after
+  model prose; the model is not trusted to preserve or generate the lists.
+- [ ] Paths with newlines, controls, tag delimiters, invalid UTF-8, or excessive
+  length cannot forge block structure; the encoding/rejection rule is pinned
+  and tested.
+- [ ] The compact journal record stores optional structured `read_files` and
+  `modified_files` details, remains backward-compatible with old summary-only
+  records, and restores the sets on resume.
+- [ ] `/clear` clears cumulative file state. A failed or cancelled compaction
+  changes neither the active summary nor its file sets.
+- [ ] Two successive `/compact` operations, resume-then-compact, read-then-edit
+  promotion, duplicates, malformed tool input, and empty lists have regression
+  coverage.
+- [ ] `docs/compaction.md` documents the blocks, cumulative semantics, and that
+  they describe structured tool calls rather than an audited filesystem log.
+
+## Code-cost analysis
+
+Expected size: **medium, about 500-850 lines including tests and docs, across
+4-6 focused commits**.
+
+| Seam | Likely work | Cost/risk |
+| --- | --- | --- |
+| Collection/formatting | Bounded path sets, JSON argument extraction, modified-wins normalization, safe block rendering | 180-300 production lines; medium untrusted-input risk |
+| Durable state | Extend compact journal records and replay with optional arrays; carry/reset state in the coordinator | 100-180 production lines; medium I14 compatibility risk |
+| Integration | Merge prior details with the compacted Projection and append generated blocks exactly once | 50-100 production lines |
+| Tests/docs | Pure collector tests plus journal, resume, clear, and repeated-compaction integration coverage | 220-350 lines |
+
+Using optional journal fields costs more than parsing tags back out of prose,
+but keeps cumulative state structural and makes malformed model output
+irrelevant. It is still a bounded change and introduces no dependency or new
+privilege.
+
+## Blocked by
+
+None - can start immediately. If ticket 01 is implemented concurrently, land
+the shared compaction module first to avoid overlapping edits.
+
+## Triage decision
+
+Decide whether a failed file-tool call should be tracked as model-visible work
+history (pi tracks calls) or omitted as an unsuccessful operation. The cheap
+pi-compatible default is to track accepted tool calls and document that the
+lists are not proof that an operation succeeded.
+
+## Comments
blob - /dev/null
blob + a1ee71280dbc6e9b4aa380b46c6066acfce926f4 (mode 644)
--- /dev/null
+++ .scratch/context-compaction/issues/03-auto-compaction-with-overflow-recovery.md
@@ -0,0 +1,124 @@
+# Add automatic compaction with one-shot overflow recovery
+
+Status: needs-triage
+Type: task
+
+## What to build
+
+Add automatic context compaction around the canonical Lead Turn. Trigger it
+before a Generation when:
+
+```text
+contextTokens > contextWindow - reserveTokens
+```
+
+Use defaults of 16,384 reserve tokens and 20,000 recent tokens kept. Select a
+cut by walking backward over journal/Projection entries, normally cutting at a
+Turn boundary and never at a tool-result message. If one Turn alone exceeds
+the keep budget, summarize its early prefix separately and retain a valid
+Assistant boundary with every tool call/result pair intact.
+
+If a provider rejects a request specifically for context overflow, compact the
+same Turn state and retry that rejected Generation exactly once. Never rerun an
+already executed tool, never retry after accepted content, and never recurse if
+the summarization request itself overflows.
+
+This adopts the high-value behavior documented in pi's
+[`docs/compaction.md`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md),
+adapted to fugu's append-only Journal, active Turn transaction, provider-neutral
+Generation, and privilege-separated API worker.
+
+## Current behavior and implementation constraints
+
+- `/compact` is manual, requires an idle `turn_txn`, summarizes the entire
+  Projection, journals one summary string, and resets all messages.
+- `/context` has a byte-derived estimate and `effective_window()` can resolve a
+  configured, provider-reported, or compiled window. There is no tokenizer for
+  the unsent Projection.
+- `turn_run()` treats every provider error as terminal failure. Provider error
+  type/message currently collapse into a display string, so overflow cannot be
+  classified safely without extending the Generation/API seam.
+- A Lead Turn accepts its User message, Assistant tool calls, and tool results
+  into an active transaction before commit. Prefix replacement therefore must
+  preserve `pending_first`, Projection receipts, cache state, and journal
+  abandonment semantics.
+- Resume currently treats a compact record as a full reset. Keeping a recent
+  suffix requires a durable cut reference or a bounded retained-message
+  snapshot; prose alone is insufficient.
+
+## Acceptance criteria
+
+- [ ] Auto-compaction defaults to enabled and uses named defaults of 16,384
+  reserve tokens and 20,000 kept tokens; configuration syntax, validation, and
+  disable behavior are documented and tested.
+- [ ] With a known effective window, the strict `>` threshold is evaluated on
+  every prospective Lead Generation, including continuations after tool
+  results. With an unknown window, threshold compaction is skipped but typed
+  overflow recovery remains available.
+- [ ] `contextTokens` uses one documented conservative estimator over the
+  actual prospective Projection. Provider usage may calibrate it but stale
+  usage from a prior request is never treated as the exact next-request size.
+- [ ] The cut planner normally retains whole recent Turns up to the 20,000-token
+  target and never starts retained context with a tool-result message or leaves
+  a tool call without its result.
+- [ ] A Turn larger than the keep target is split only at a valid User or
+  Assistant boundary. Its early prefix receives a distinct Turn-context
+  summary that is merged with the history summary before the valid suffix.
+- [ ] Compaction can replace a prefix while the transaction is idle or active
+  without committing, abandoning, duplicating, or losing pending Turn state.
+- [ ] The compact journal record durably identifies or contains the retained
+  suffix, and resume reconstructs exactly the same provider-valid Projection.
+- [ ] Provider adapters/API IPC expose a bounded typed context-overflow
+  category based on HTTP status and provider error type/code—not broad English
+  substring matching. Unknown errors retain today's failure behavior.
+- [ ] An overflow-rejected Generation with no accepted content compacts and is
+  retried once. A second overflow, any post-content failure, cancellation, or
+  compaction failure ends the Turn conspicuously with no further retry.
+- [ ] Tool side effects occur at most once across recovery; tests cover an
+  overflow after a completed tool-result batch and prove tools are not rerun.
+- [ ] Prompt-cache receipts reset after a cut, usage remains additive and
+  nonduplicated, and the UI distinguishes summarization from the retry.
+- [ ] Manual `/compact` remains available and shares the serializer, summary
+  policy, durable state, and safety checks rather than maintaining a second
+  implementation.
+- [ ] `docs/compaction.md`, `fugu(1)`, `fugu.conf(5)`, behavior, design, and
+  verification docs define threshold, cuts, split Turns, overflow recovery,
+  unknown-window behavior, and the exactly-once limit.
+
+## Code-cost analysis
+
+Expected size: **medium-large and high-risk, about 1,800-3,000 lines including
+tests and documentation, across 7-11 reviewable commits**. The value is high,
+but this is not a one-condition change: the cost is transactional correctness
+and durable replay.
+
+| Seam | Likely work | Cost/risk |
+| --- | --- | --- |
+| Token accounting and cut planner | Prospective Projection estimator, effective-window policy, Turn/tool-pair boundary planner, split-Turn preparation | 220-380 production + 300-500 test lines; medium algorithmic risk |
+| Shared compactor | Reuse ticket 01 serialization, summarize history/prefix, merge prior summary/details, enforce no-tools and bounded output | 140-260 production + 180-300 test lines |
+| Turn transaction and Journal | Prefix replacement while idle/active, retained-suffix durability, replay, cache receipt reset, clear/abandon behavior | 220-380 production + 350-550 test lines; high I14 risk |
+| Provider error typing | Preserve provider error code/type and HTTP classification through `fugu-api` → Generation → Turn result | 120-220 production + 180-300 test lines; medium protocol risk |
+| Retry orchestration | Re-enter only the rejected Generation, cap at one, preserve usage and tool exactly-once semantics | 160-300 production + 250-450 integration-test lines; high side-effect risk |
+| Config/UI/docs | Defaults, parser/man page, summarize/retry notices, `docs/compaction.md` and verification matrix | 120-220 implementation/doc lines + config tests |
+
+The estimate assumes tickets 01 and 02 land first. Without them, add roughly
+700-1,100 lines and two to four commits. No new runtime dependency or privilege
+is required; the security boundary stays the same, but the coordinator and
+Journal state machines gain new states that require adversarial crash/resume
+tests.
+
+## Blocked by
+
+- [01 — Serialize conversation history for summarization](./01-serialize-conversation-for-summarization.md)
+- [02 — Carry cumulative file tracking through compaction summaries](./02-cumulative-file-tracking-in-summaries.md)
+
+## Triage decisions
+
+1. Pin the configuration names and whether model-specific `context_limit`
+   remains an override for auto-compaction.
+2. Choose the durable suffix representation: stable Journal entry references
+   or an explicit retained-message snapshot in the compact record.
+3. Pin provider-specific overflow codes/statuses from captured primary-source
+   fixtures before implementing the typed classifier.
+
+## Comments
blob - /dev/null
blob + e6be03890dd3165767eaab2da1f7f733a2665e2e (mode 644)
--- /dev/null
+++ .scratch/context-epochs/issues/01-evaluate-immutable-context-epochs.md
@@ -0,0 +1,94 @@
+# Evaluate immutable Context Epochs and chronological context deltas
+
+Status: needs-triage
+Type: research
+
+## Research question
+
+Should fugu stop rebuilding its privileged system block when context changes
+and instead persist an immutable Context Epoch plus chronological, model-visible
+“context changed” deltas?
+
+OpenCode V2's
+[`specs/v2/session.md`](https://github.com/anomalyco/opencode/blob/dev/specs/v2/session.md)
+persists the exact privileged System Context shown to the model as an immutable
+baseline. At safe provider-turn boundaries it observes context sources and, if
+one changed, atomically journals one chronological System message and advances
+the epoch snapshot. A completed compaction or Session move starts a fresh
+baseline. This preserves history semantics and allows an unchanged provider
+cache prefix to remain valid.
+
+Fugu currently composes `sysfull` from the base prompt, personal context,
+project context, and compaction notes. It journals personal/project bytes and
+the summary, rebuilds after startup, `/clear`, and `/compact`, and deliberately
+starts cache state cold after `/clear`, `/compact`, `/model`, and resume. Its
+canonical conversation has only User and Assistant roles, so chronological
+System deltas would be a real model/journal/provider change rather than a
+different representation of existing state.
+
+## What to investigate
+
+1. Pin the OpenCode source revision and describe Context Epoch admission,
+   source observation, atomic delta publication, compaction reset, unavailable
+   source behavior, and prompt-cache intent.
+2. Inventory every fugu system-context source and every time it can actually
+   change during a Session: configured/built-in system prompt, personal and
+   project context, `#` capture, `/clear`, `/compact`, model/provider selection,
+   skills, environment/date facts, and resume under changed config/files.
+3. Verify or reject the premise that fugu silently rewrites prior context.
+   `journal_context()` already snapshots project/personal bytes for replay;
+   determine exactly which baseline inputs remain unjournaled and which live
+   changes are not re-observed at all.
+4. Compare three designs:
+   - keep full-system rebuilds and document their cache/replay semantics;
+   - journal full immutable Context Epoch snapshots but still rebuild on an
+     explicit epoch boundary; or
+   - add canonical chronological System-delta messages at safe boundaries.
+5. For each design, analyze Anthropic and Claude Subscription cache breakpoint
+   stability, OpenAI behavior, provider request validity, context growth,
+   compaction interaction, prompt-injection presentation, and crash/resume
+   fidelity under invariant I14.
+6. Measure or estimate whether cache savings justify adding a System role and
+   context-observation state machine in a one-Owner local process.
+
+## Deliverable
+
+A checked-in research note with:
+
+- a source-by-source current-state and replay table;
+- a timeline comparison for startup → context edit → next Turn → compaction →
+  resume;
+- explicit findings for semantic fidelity and provider-cache validity;
+- a recommendation to retain, partially adopt, or fully adopt Context Epochs;
+- an ADR proposal if the canonical conversation or Journal contract should
+  change; and
+- separately scoped implementation tickets with code-cost estimates only if
+  adoption is recommended.
+
+## Acceptance criteria
+
+- [ ] The mutable OpenCode `dev` source is pinned to a commit.
+- [ ] Claims about current fugu behavior cite code, behavior/design docs, and
+  journal/cache regressions; the ticket's premise is tested, not assumed.
+- [ ] The analysis distinguishes a context-source snapshot, a system baseline,
+  a chronological delta, a compaction summary, and prompt-cache bookkeeping.
+- [ ] `/clear`, `/compact`, `#` capture, model switch, changed files before
+  resume, and changed config before resume each receive an explicit verdict.
+- [ ] Any proposal preserves fugu's provider-neutral Projection validity and
+  append-only crash/replay invariants.
+- [ ] The recommendation quantifies implementation complexity and expected
+  cache benefit well enough to decide whether to build it.
+
+## Research cost
+
+Expected size: **medium, roughly 250-500 lines of evidence and design analysis**.
+The research is inexpensive; full adoption would likely be medium-to-large
+because it touches the canonical message roles, request builders, Journal
+replay, context observation, compaction, and all provider cache tests.
+
+## Blocked by
+
+None - can start immediately. Coordinate conclusions with the context
+compaction tickets because compaction is an epoch boundary in both designs.
+
+## Comments
blob - /dev/null
blob + 9796c7c5c1500c732624e5ef9ac18020942b7952 (mode 644)
--- /dev/null
+++ .scratch/durable-tool-output/issues/01-retain-bounded-tool-output-artifacts.md
@@ -0,0 +1,115 @@
+# Retain full tool output as private, bounded artifacts
+
+Status: needs-triage
+Type: task
+
+## What to build
+
+Treat output bounding as a settlement boundary. The model still receives a
+small bounded preview, but output beyond that preview is retained—up to a
+larger hard artifact cap—in Owner-only managed storage. The result names an
+opaque output ID, retained byte count, truncation state, and the paged tool the
+model can use to inspect another range without rerunning the command.
+
+Start with foreground `shell`, `grep`, `find`, and `ls`, whose subprocess
+capture currently stops at 256 KiB, and `shell_output`, whose 256 KiB ring drops
+overflow. Decide explicitly whether brokered web output belongs in the first
+slice or a follow-up.
+
+Do **not** add OpenCode's stale-registration rejection. Fugu's tool definitions
+are static C objects selected from startup configuration/skills; it has no
+runtime plugin registry, registration generation, or stale executor handle to
+reject. Revisit that rule only if dynamic tools/MCP/plugins are introduced.
+
+## Why the safe version is not just a larger buffer
+
+- Foreground subprocesses are killed when capture exceeds 256 KiB today, so
+  bytes beyond the marker do not exist to recover.
+- Under `allow_write no`, `fugu-tool` has no `wpath`/`cpath`; creating a file in
+  the project would weaken the kernel-enforced read-only mode.
+- The coordinator can create private 0600 files only inside its existing
+  session unveil, while the tool worker cannot open that directory.
+- A raw session path is not useful to the model because normal file tools
+  cannot read it, and placing generated logs in the project risks accidental
+  commits and secret disclosure.
+- Truly unbounded retention permits disk exhaustion. “Full” must mean complete
+  up to a documented per-artifact and aggregate-session quota, followed by a
+  second conspicuous truncation boundary.
+
+The preferred architecture is therefore a coordinator-owned artifact store.
+The coordinator creates a private file and passes only a write descriptor to
+the executing worker; the worker streams to it while constructing the preview.
+A provider-visible retrieval tool addresses the artifact by unpredictable ID
+and returns bounded offset/limit windows. No role receives a wider path unveil.
+
+## Acceptance criteria
+
+- [ ] Tool results return a bounded, useful preview and structured metadata:
+  opaque ID, total bytes observed, retained bytes, whether the preview was
+  abbreviated, and whether the artifact hard cap was reached.
+- [ ] Preview policy is pinned (for example useful head and tail regions) and
+  remains within the existing per-tool/Turn model-context bounds.
+- [ ] A new paged retrieval tool reads an artifact by ID with byte or line
+  offset/limit, reports the next cursor and total retained size, and never
+  accepts a filesystem path.
+- [ ] Artifact IDs are unguessable within the process, length-checked, bound to
+  the current Session, and cannot traverse or alias another Owner file.
+- [ ] Files and directories are Owner-only (0600/0700), created atomically
+  under the existing session authority, and never placed in the project tree.
+- [ ] The executor receives only pre-opened descriptors. `allow_write no`,
+  subagent read-only confinement, API/web no-file authority, and every current
+  unveil set remain unchanged and are proved by sandbox regressions.
+- [ ] Foreground commands continue after the 256 KiB preview boundary but are
+  killed at timeout or the larger artifact cap. Disk writes and IPC draining
+  are bounded so a fast producer cannot exhaust memory or starve the event
+  loop.
+- [ ] Background jobs spool incrementally instead of silently dropping ring
+  overflow; polling remains bounded and does not replay bytes already returned.
+- [ ] Artifact creation/finalization and the corresponding tool-result
+  settlement have defined crash behavior. A failed or abandoned Turn cannot
+  leave a journaled ID that points to another Turn's data.
+- [ ] Resume either preserves journaled artifact IDs for paged retrieval or
+  marks them durably unavailable; the chosen retention and cleanup policy is
+  explicit and tested.
+- [ ] Per-artifact, per-Session, and global cleanup/quota behavior prevents disk
+  exhaustion and handles a full filesystem conspicuously without losing the
+  bounded model result.
+- [ ] Tests cover exact preview boundaries, multi-megabyte output, binary/NUL
+  bytes, invalid IDs/cursors, timeout, hard-cap termination, cancellation,
+  crash/resume, read-only mode, concurrent subagents/jobs, and cleanup.
+- [ ] Behavior, security caveats, tool descriptions, manuals, and the
+  verification matrix explain that retained output may contain secrets and is
+  private Session state.
+
+## Code-cost analysis
+
+Expected size: **medium-large, roughly 1,000-1,800 lines including tests and
+documentation, across 6-9 commits**. A project-local tempfile would be much
+smaller, but would fail under `allow_write no`, pollute the working tree, and
+weaken fugu's authority model.
+
+| Seam | Likely work | Cost/risk |
+| --- | --- | --- |
+| Artifact store | ID generation, private files, quotas, finalize/reopen/cleanup, crash behavior | 180-320 production lines; high disk/state risk |
+| Descriptor protocol | Coordinator creates/passes fds; tool worker streams without new unveil rights | 100-200 production lines; medium IPC risk |
+| Capture and jobs | Tee subprocess output to artifact while retaining bounded preview; replace lossy job ring semantics | 180-320 production lines; high backpressure/process risk |
+| Retrieval tool | Definition, argument validation, bounded paging, Session lookup | 120-220 production lines |
+| Tests/docs | Pure store tests, tool/Turn integration, sandbox/ktrace, resume/quota adversarial cases, manuals | 400-700 lines |
+
+## Blocked by
+
+None - can start after triage, but storage lifetime and quota choices must be
+pinned before implementation.
+
+## Triage decisions
+
+1. Per-artifact and per-Session hard caps, and whether hitting the hard cap
+   kills the producer or continues while discarding.
+2. Preview shape: head only, head+tail, or tool-specific.
+3. Artifact lifetime across successful, failed, abandoned, and resumed Turns.
+4. Whether the first slice covers web responses and subagent-internal tools or
+   only Lead local subprocess tools/background jobs.
+5. Whether Owner-facing output exposes the private path while the model sees
+   only the opaque ID.
+
+## Comments
blob - /dev/null
blob + 40b2055ad61274f3a3f3f657a596f1e53de365a7 (mode 644)
--- /dev/null
+++ .scratch/model-thinking-effort/issues/01-configurable-thinking-effort.md
@@ -0,0 +1,59 @@
+# Configurable model thinking effort
+
+Status: needs-triage
+Type: task
+
+## Problem
+
+fugu has no way to select a model's thinking or reasoning effort. The
+configuration carries the model, output-token cap, and context-window override,
+but no effort setting. The Anthropic, OpenAI, and experimental Claude
+Subscription request builders therefore rely entirely on provider defaults.
+
+This is especially visible with models that expose named levels such as
+`low`, `medium`, `high`, and `xhigh`: changing models may silently change the
+amount of reasoning because fugu cannot preserve an explicit Owner choice.
+
+## Evidence
+
+- `struct fugu_conf` and the parser have no effort field or directive.
+- `anthropic_build_request()`, `openai_build_request()`, and
+  `claude_sub_build_request()` emit no thinking/reasoning effort parameter.
+- The experimental Claude Subscription compatibility profile advertises an
+  effort beta, but its request adapter does not currently use it.
+
+## Desired behavior
+
+Add an optional symbolic effort setting that can express the levels supported
+by the selected model/provider. Leaving it unset must preserve today's
+provider-default behavior.
+
+The selected value must reach every Generation consistently, including tool
+continuations and compaction. The design must explicitly decide whether
+subagents inherit the Lead setting or receive a separate override.
+
+## Acceptance criteria
+
+- Configuration accepts and validates an effort directive with a documented
+  set of symbolic values.
+- `fugu -n` prints the effective nonsecret value.
+- Each supported provider adapter maps the symbolic value to its native wire
+  field; an unsupported provider/model combination fails conspicuously or has
+  a documented fallback rather than silently selecting a different level.
+- The value survives model selection and every Generation within a Turn.
+- Subagent inheritance or override behavior is explicit and covered by tests.
+- Request-builder regressions assert the exact wire shape for every supported
+  provider, and config regressions cover valid, invalid, default, and match
+  precedence behavior.
+- `fugu.conf(5)` documents values, defaults, provider limitations, and examples.
+
+## Triage decisions needed
+
+1. Directive name: `effort`, `thinking_effort`, or another term.
+2. Canonical value set and how provider-specific names such as `xhigh`/`max`
+   map without pretending unsupported levels are equivalent.
+3. Whether subagents inherit the Lead value or gain `subagent_effort`.
+4. Whether unsupported model/provider combinations are rejected locally or
+   passed through for the provider to reject.
+
+## Comments
blob - /dev/null
blob + 6ad7e502e72b41786db571a40afe597dcd79f659 (mode 644)
--- /dev/null
+++ .scratch/prompt-admission-semantics/issues/01-compare-steer-and-queue-promotion-rules.md
@@ -0,0 +1,84 @@
+# Compare steer and queue promotion rules with OpenCode V2
+
+Status: needs-triage
+Type: research
+
+## Research question
+
+Does fugu's specification completely define when queued and steered User input
+becomes model-visible, especially when multiple inputs arrive around tool
+continuations and the per-Turn tool allowance?
+
+OpenCode V2 separates durable admission (`PromptAdmitted`) from atomic
+promotion into model-visible history (`Prompted`). Fugu already has a cleaner
+single-process analogue: the tty queue is pending interface state, steers enter
+the active Turn transaction only at a safe tool-result boundary, and the
+Pending Projection becomes durable only when the Turn commits. There is no
+structural inbox architecture to adopt.
+
+The useful comparison is OpenCode's more explicit promotion policy in
+[`specs/v2/session.md`](https://github.com/anomalyco/opencode/blob/dev/specs/v2/session.md):
+
+- steers promote at the next safe provider-turn boundary;
+- queues remain FIFO while the current drain requires continuation;
+- when execution would become idle, promote exactly one queued input and then
+  reevaluate continuation before promoting another; and
+- multiple steers promoted at one boundary reset the provider-turn allowance
+  once, not once per steer.
+
+## What to investigate
+
+1. Pin the OpenCode source revision and restate its `PromptAdmitted` →
+   `Prompted`, steer, queue, and allowance rules as a small state machine.
+2. Trace fugu's actual behavior through `fugu-tty` queue ownership,
+   `UI_STEER`, `coord.c`'s aggregated `steer` buffer, `turn_mechanics`, the
+   Turn transaction, Journal commit/abandon, and resume.
+3. Compare specification, implementation, and regressions for at least:
+   multiple steers before one tool-result boundary; a steer after the final
+   provider response; two or more FIFO queue entries; a queued prompt whose
+   Turn itself continues through tools; interleaved queue/steer input;
+   failure/cancellation/session switch; and resume.
+4. Determine what “reset the Turn allowance once” should mean in fugu, whose
+   50-step tool limit currently belongs to one canonical Turn. Do not assume
+   OpenCode's counter has identical semantics.
+5. Identify specification gaps separately from implementation bugs. Do not
+   propose a durable inbox or multi-process event architecture unless a
+   concrete fugu requirement needs one.
+
+## Deliverable
+
+A checked-in research note containing:
+
+- an OpenCode-to-fugu state/transition table;
+- exact evidence links and fugu file/line references;
+- a verdict for each OpenCode rule: already guaranteed, implemented but
+  underspecified, missing test, or behavior change needed;
+- proposed replacement wording for `handoff/behavior.md` and
+  `handoff/verification.md` where precision is missing; and
+- separate implementation tickets only for confirmed code changes.
+
+## Acceptance criteria
+
+- [ ] The OpenCode commit is pinned; mutable `dev` documentation is not treated
+  as timeless evidence.
+- [ ] The analysis distinguishes admission, promotion, provider dispatch,
+  Journal acceptance, Turn commit, and UI dequeue.
+- [ ] “Promote one queue entry, then reevaluate” and “one allowance reset per
+  steer batch” each receive an explicit fugu verdict.
+- [ ] Existing regressions are mapped to the edge cases they prove, and missing
+  cases are named with proposed fixtures/assertions.
+- [ ] The recommendation preserves fugu's one-Owner, one-process queue design
+  unless evidence demonstrates a real durability requirement.
+
+## Research cost
+
+Expected size: **small, one focused research pass and roughly 150-300 lines of
+notes/spec proposals**. If the implementation matches but the contract is
+ambiguous, the follow-up should be docs plus targeted regression cases rather
+than a runtime refactor.
+
+## Blocked by
+
+None - can start immediately.
+
+## Comments
blob - /dev/null
blob + 7706e4d36efa39d28cca044d88537b2435c88814 (mode 644)
--- /dev/null
+++ .scratch/user-message-background/issues/01-grey-background-for-user-transcript-messages.md
@@ -0,0 +1,60 @@
+# Render transcript User messages on a grey background
+
+Status: needs-triage
+Type: task
+
+## What to build
+
+Give each User message in the curses transcript a grey-background block so it
+is visually distinct from light-grey Assistant prose and indented cyan tool
+activity. Reuse the same palette/treatment intended for the User input area so
+composing and reviewing the Owner's words feel like one visual language.
+
+The block must include the `> ` prefix, wrapped continuation lines, and padding
+to a deliberate edge. It must not recolor Assistant output or change line and
+print modes.
+
+## Acceptance criteria
+
+- [ ] Color terminals render every `IT_USER` transcript item with the chosen
+  grey background and readable foreground contrast.
+- [ ] All visual rows belonging to one wrapped or multiline User message have
+  coherent background fill; no unpainted gaps appear between the prefix, text,
+  continuation indent, and chosen right edge.
+- [ ] The treatment shares a named color-pair/palette helper with the input
+  area rather than duplicating numeric curses attributes.
+- [ ] Monochrome terminals retain a clear, readable fallback (for example
+  bold/reverse) without relying on color.
+- [ ] Narrow terminals, wide/combining UTF-8, `ascii yes`, resize/reflow,
+  scrollback, and the conversation drawer preserve correct geometry.
+- [ ] Headless renderer tests pin attributes and wrapping, and the pty
+  regression proves terminal startup/teardown remains clean.
+- [ ] `handoff/behavior.md`, `docs/design/m7-curses.md`, and `fugu(1)` describe
+  User transcript styling without pinning an implementation-only color number.
+
+## Code-cost analysis
+
+Expected size: **small, about 120-240 lines including tests/docs, in 2-3
+commits**. The text attribute is easy; the real cost is painting padding across
+every wrapped display row without bleeding the background into adjacent
+transcript items.
+
+Likely touch points are `src/fugu-tty/ui.c`, a render-focused regression (or a
+small extraction that makes span attributes testable), the curses pty check,
+and the three interface documents.
+
+## Blocked by
+
+None - can start immediately.
+
+## Triage decisions
+
+1. Should the block fill the transcript width, the content width plus padding,
+   or a fixed percentage/maximum width?
+2. Is “grey box used for User input” already a desired-but-unimplemented style
+   (the current `draw_input()` has no background pair), in which case this
+   ticket should style both surfaces together?
+3. Pick foreground/background values for 8-color terminals and define the
+   monochrome fallback.
+
+## Comments
blob - 4763e2d1c582f79c0fc3fdf32bea3a3e753638cd
blob + b276003cb91cfc767e9147af854ff9031fdd8fab
--- docs/design/m7-curses.md
+++ docs/design/m7-curses.md
@@ -2,10 +2,10 @@
 
 Milestone M7 builds the full-screen terminal front end (behavior.md
 §2.1): scrolling transcript, modal vi-like input editor, status bar,
-markdown and diff rendering, scrollback, bracketed paste, the external
-editor, the conversation drawer, and the model picker. It is by far the
-largest milestone, so it is split into testable pieces that land green
-one at a time.
+markdown and diff rendering, mouse-wheel scrollback, bracketed paste,
+the external editor, the conversation drawer, and the model picker. It
+is by far the largest milestone, so it is split into testable pieces
+that land green one at a time.
 
 ## Where the UI runs, and why
 
@@ -67,11 +67,14 @@ Render events (coordinator → fugu-tty), each a lengt
 - `T_CONFIG` — ascii, notify, mode label, effective window, model id.
 - `T_USER` — echo a submitted user line into the transcript.
 - `T_TEXT` — an assistant text delta, appended to the live block.
-- `T_TOOL` — a tool call line (name + one-line summary).
+- `T_TOOL` — an indented tool call line: name plus a bounded, control-safe
+  allowlist of useful arguments (paths, search patterns, line windows, and
+  similar action identifiers; never bulk content, HTTP headers/bodies, edit
+  replacements, or subagent prompts).
 - `T_DIFF` — a formatted diff hunk (presentation only).
 - `T_ERROR` — an error line, visually distinct.
 - `T_STATUS` — status-bar state: idle/busy/summarizing/retry (with
-  attempt and delay), plus the usage counters and percent-of-window.
+  attempt and delay), plus the committed Lead context count.
 - `T_DONE` — turn (or summarization) complete; fugu-tty bells if
   `notify`.
 - `T_CLEARED` — drop the transcript (after `/clear`).
@@ -154,13 +157,14 @@ provider active for the session and cold-starts the pr
 ## Status bar and the context meter
 
 The status bar shows idle/busy/summarizing/retry (retry naming the
-attempt and pending delay), the input mode, and — once a usage report
-exists — input/output and cache token counts plus percent-of-window
-against the effective window (`context_limit`, else provider-reported,
-else the compiled table, else unknown). At or above 80% the field is a
-warning attribute and the first crossing emits a one-line `/compact`
-notice. On completion the bell fires unless `notify no`. Line and print
-modes never bell.
+attempt and pending delay), the input mode, and — once a Lead usage report
+commits — `Context current/max N%` against the effective window (`context_limit`,
+else provider-reported, else the compiled table, else unknown). `current`
+combines the main conversation's cache and session tokens without ephemeral
+subagent usage. Counts of at least 1,000 are rounded to the nearest `k`. At or
+above 80% the field is a warning attribute and the first crossing emits a
+one-line `/compact` notice. On completion the bell fires unless `notify no`.
+Line and print modes never bell.
 
 ## Status
 
@@ -168,7 +172,8 @@ Built and green (both builds): the render/edit/diff/in
 (`markdown.c`, `editor.c`, `diff.c`, `term_input.c`, each unit-tested),
 the coordinator↔tty render/input protocol and terminal-mode driver, and
 the curses front end (`ui.c`) — layout, markdown/diff/tool/error
-rendering, scrollback, status bar with the context gauge, bell,
+rendering, scrollback (including xterm basic/SGR mouse-wheel reports),
+status bar with the context gauge, bell,
 bracketed paste, resize, modal editing, completion, history, and the
 message queue. A pty driver exercises the whole path end to end.
 
blob - /dev/null
blob + 2af38effe39ffa1982e46c542826e56f3d56f05c (mode 644)
--- /dev/null
+++ docs/research/pi-supply-chain-posture.md
@@ -0,0 +1,275 @@
+# Pi supply-chain posture: what fugu should adopt
+
+Research date: 2026-07-16
+
+Pi source inspected: the current canonical `earendil-works/pi` repository at
+commit
+[`216e672`](https://github.com/earendil-works/pi/tree/216e672e7c9fc65682553394b74e483c0c9e47f7).
+The former `badlogic/pi-mono` URL redirects there.
+
+## Recommendation
+
+Do **not** copy Pi's npm controls wholesale. They are good controls for Pi's
+large, registry-resolved Node dependency graph, but fugu has deliberately
+removed that attack surface: it links only OpenBSD base libraries, has no
+language-registry dependency resolution step, and carries one small source
+dependency in the repository.
+
+Adopt two ideas in forms native to fugu:
+
+1. **Adapt exact pinning into explicit vendored-source provenance.** Record the
+   upstream URL, commit, and digest for `src/common/jsmn.h`, and optionally
+   verify the digest in `make check`. The file is already fixed by the fugu Git
+   commit; the missing part is an auditable upstream identity.
+2. **Adopt the outside-the-repository artifact test in `make live-check`.**
+   Fugu already does most of this more strongly than Pi by building an OpenBSD
+   package and installing it on a clean host. Make the artifact-only property
+   explicit by running the installed binary from a temporary project outside
+   the checkout, with development worker/config overrides unavailable.
+
+Reject `min-release-age`, `--ignore-scripts`, and a lifecycle-script allowlist
+for the current tree. They control registry selection and package-manager
+execution that do not occur in fugu. Reconsider them only if fugu later adds a
+registry dependency, ports library dependency, language runtime, generated
+foreign build step, or plugin/package ecosystem.
+
+## Decision matrix
+
+| Pi control | Decision for fugu | Reason | Estimated cost |
+|---|---|---|---|
+| Exact-pin dependencies | **Adapt; mostly satisfied already** | Committed vendored source and checksummed release distfiles are immutable inputs, but jsmn's upstream commit/digest is not recorded | Low: documentation alone is under an hour; an enforced digest/provenance regression is roughly 20-60 lines and a few hours including tests |
+| Minimum release age | **Reject now** | No registry resolver selects fresh releases during a fugu build or install | No implementation cost; a custom age mechanism would add maintenance without covering a current path |
+| `--ignore-scripts` everywhere | **Reject as Node-specific** | No npm lifecycle scripts execute; BSD make and ports hooks are first-party, reviewed build instructions and cannot simply be disabled | No implementation cost; preserve the existing no-runtime/no-ports-dependency charter |
+| Lifecycle-script allowlist | **Reject now; add only with such dependencies** | There is no lifecycle-script metadata or dependency graph to inspect | No implementation cost today; a premature checker would be policy theatre |
+| Release install outside the repo | **Adopt and strengthen `make live-check`** | Detects source-tree fallback, missing package files, wrong installed paths, and environment leakage | Low-medium: about half to one day for shell changes, regression coverage, documentation, and one clean-host validation; no new runtime dependency |
+
+The estimates assume the current OpenBSD-only architecture and include tests
+and documentation, but not release-machine provisioning.
+
+## What Pi actually implements
+
+Pi's stated policy is that npm dependency changes are reviewed code changes.
+Its current README lists exact direct dependency versions, a lockfile and
+published shrinkwrap, a two-day release-age gate, lifecycle-script suppression,
+a lifecycle-script review list, audits, and isolated local release installs.
+See the
+[`Supply-chain hardening` section](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/README.md#L61-L73).
+
+### Exact direct and transitive versions
+
+Pi sets `save-exact=true` and checks every external entry in
+`dependencies`, `devDependencies`, and `optionalDependencies` against an exact
+semantic-version pattern. Workspace and non-registry references are treated
+separately. The enforcement is source-visible in
+[`check-pinned-deps.mjs`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/check-pinned-deps.mjs#L1-L63),
+not just a README convention.
+
+The root lockfile fixes development resolution, while the published coding
+agent carries a generated `npm-shrinkwrap.json` for its transitive production
+closure. This matters for Pi because an exact direct dependency can still
+declare ranged transitive dependencies. Fugu has no corresponding resolver or
+transitive package graph.
+
+### Two-day release cooldown
+
+Pi's
+[`.npmrc`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/.npmrc#L1-L2)
+sets `min-release-age=2`. npm defines that value in days: versions newer than
+the window are excluded from resolution. npm also documents the tradeoff that
+the gate can temporarily block a newly published security fix, requiring an
+exception or a relaxed window; see the official
+[`min-release-age` configuration](https://docs.npmjs.com/cli/v11/commands/npm-install/#min-release-age).
+
+This is a quarantine control, not integrity verification. It gives registry
+and community detection systems time to react to a malicious new publication,
+but it neither proves that an older version is safe nor protects a version
+already fixed in a lockfile from a compromised download channel.
+
+### Lifecycle scripts: suppression plus review
+
+Pi's development, CI, local-release install, publish, and self-update paths use
+`--ignore-scripts` where supported. The policy is visible in the
+[CI install](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/.github/workflows/ci.yml#L32-L39),
+[publish path](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/publish.mjs#L52-L53),
+and
+[self-update construction](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/packages/coding-agent/src/config.ts#L115-L182),
+as well as the local-release source below. npm defines that flag as suppressing
+scripts from package manifests while still allowing an explicitly requested
+`npm run` command; see npm's
+[`ignore-scripts` semantics](https://docs.npmjs.com/cli/v11/commands/npm-install/#ignore-scripts).
+This removes the install-time code-execution path used by malicious
+`preinstall`, `install`, and `postinstall` scripts.
+
+Pi also inspects the generated production shrinkwrap for packages marked
+`hasInstallScript`. The current allowlist names two exact package versions and
+records why their scripts are considered harmless; any new marked package
+fails generation until reviewed. See the
+[`allowedInstallScriptPackages` list](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/generate-coding-agent-shrinkwrap.mjs#L7-L16)
+and the
+[`hasInstallScript` validation](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/generate-coding-agent-shrinkwrap.mjs#L224-L260).
+In Pi this is primarily an audit gate: normal install paths still suppress the
+scripts. It ensures that a dependency cannot silently acquire script-bearing
+metadata without review.
+
+### The outside-repository release check
+
+Pi's `release:local` script requires its output directory to be outside the
+repository, runs checks and tests, rebuilds publishable packages, packs them,
+and creates fresh npm and Bun production installs from those tarballs. See
+[`prepareOutputDirectory`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/local-release.mjs#L107-L132)
+and the
+[`pack and isolated-install sequence`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/local-release.mjs#L197-L255).
+
+One nuance matters when copying the pattern: the current script creates the
+isolated installs and prints outside-repo `pi --help` commands, but does not
+itself execute those commands. See its
+[`final smoke instructions`](https://github.com/earendil-works/pi/blob/216e672e7c9fc65682553394b74e483c0c9e47f7/scripts/local-release.mjs#L259-L283).
+The useful control is therefore artifact isolation; fugu can and should make
+the actual invocation automatic.
+
+## Fugu's current posture
+
+### Dependency surface
+
+Fugu's [dependency charter](../../handoff/charter.md) permits only OpenBSD base
+libraries in linked binaries and permits a small, permissively licensed,
+single-file vendored component only where base has no safe equivalent. The
+[port Makefile](../../port/productivity/fugu/Makefile) declares no
+`BUILD_DEPENDS`, `RUN_DEPENDS`, `LIB_DEPENDS`, `TEST_DEPENDS`, or `MODULES`; its
+only dependency declaration is the base-library `WANTLIB` set
+`c curses event tls util`. The
+[deployment regression](../../regress/deploy/deploy_test.sh) derives each
+binary's actual ELF dependencies and checks that their union exactly matches
+that set.
+
+The sole external source component is `src/common/jsmn.h`, documented in the
+[README](../../README) and isolated behind `src/common/json.c`. It is committed
+source, so every fugu commit already pins its exact bytes. At the research
+snapshot its SHA-256 is
+`c04533e9181e1e33baceb0f55ac449b05145bb936e8c68cc77dfe0d8277514fb`,
+and it is byte-identical to upstream jsmn at
+[`25647e6`](https://github.com/zserge/jsmn/blob/25647e692c7906b96ffd2b05ca54c097948e879c/jsmn.h).
+Neither the upstream commit nor digest is currently recorded in the tree. That
+is a traceability gap, not dynamic dependency drift.
+
+### Source and package integrity
+
+[`make local-port`](../../scripts/local-port) refuses a dirty worktree, archives
+the exact current Git commit, stages the maintained port under `mystuff`, and
+runs the ports framework's `makesum` and `checksum` targets. OpenBSD specifies
+that `checksum` compares every fetched distfile and patch against the SHA-256
+values in `distinfo`; see
+[`bsd.port.mk(5)`](https://man.openbsd.org/OpenBSD-7.9/bsd.port.mk.5#checksum).
+The OpenBSD porting guide also requires visible source versions rather than a
+moving `latest` file and recorded checksums for retrieved sources; see the
+official [Porting Guide](https://www.openbsd.org/faq/ports/guide.html#PortsChecklist).
+
+This is the relevant equivalent of lockfile integrity for fugu's own release
+distfile. It does not authenticate a malicious commit accepted into the fugu
+repository, but neither do Pi's exact version strings; source review and
+repository trust remain separate controls.
+
+### Build and release isolation
+
+[`make check`](../../scripts/check) removes prior build products, creates fresh
+object directories, compiles with warnings treated as failures, and runs every
+regression suite. The ports `package` target then builds from the checksummed
+archive through extraction, fake installation, and package creation. OpenBSD's
+framework verifies declared dependencies and `WANTLIB` before building and
+can check the final package's library declarations; see
+[`bsd.port.mk(5)` dependency and package targets](https://man.openbsd.org/OpenBSD-7.9/bsd.port.mk.5#prepare).
+The official testing guide additionally warns against accidentally detecting
+undeclared software already present on the build host; see the
+[OpenBSD Ports Testing Guide](https://www.openbsd.org/faq/ports/testing.html#Testing).
+
+Fugu's [`make live-check-privileged`](../../scripts/live-check) is already
+stronger than Pi's isolated install step in several respects. On a disposable
+host it rejects a pre-existing fugu package, binary, group, or configuration;
+snapshots and hashes the package; requires a hostname-plus-digest confirmation;
+installs with `pkg_add`; verifies package registration, ownership, modes, and
+files; and invokes the installed `fugu -n` as an ordinary user. The companion
+[release procedure](../release-live-checks.md) keeps credentialed model tests
+separate from privileged package installation.
+
+The remaining isolation gap is small but real: the privileged invocation
+inherits the directory from which the make target was launched, while the
+credentialed target defaults some invocations back to the fugu repository.
+Several tool and subagent checks already use a temporary project, but the
+artifact-only property is not a single enforced invariant. A packaged release
+could therefore be insufficiently tested for a source-tree fallback or an
+undeclared runtime file.
+
+OpenBSD package signing is a separate, ecosystem-native concern. Official
+packages are signed with `signify`, and OpenBSD documents how maintainers can
+sign their own packages in the
+[Package Signatures guide](https://www.openbsd.org/faq/ports/ports.html#PkgSig).
+Fugu's current clean-host check binds installation to an operator-confirmed
+package SHA-256, which is suitable for the present local one-Owner flow. If
+packages are later distributed across trust boundaries, signing them would be
+more relevant than importing npm release-age or lifecycle controls.
+
+## Threat and cost analysis
+
+Pi's controls address three high-probability Node threats:
+
+- semver or transitive resolution selecting an attacker-controlled registry
+  publication;
+- package lifecycle code executing before the dependency is reviewed; and
+- a published tarball working only because it can see monorepo files or a
+  hoisted development dependency.
+
+For fugu, the first two paths are absent. The comparable current threats are:
+
+- an unreviewed change to the committed jsmn bytes or loss of its upstream
+  provenance;
+- a release archive differing from the reviewed commit;
+- a package omitting a worker, manual, sample, permission, or other runtime
+  resource; and
+- an installed binary accidentally relying on the source checkout, development
+  environment variables, or build-host state.
+
+The current Git, ports checksum, package, ELF dependency, and clean-host checks
+already cover most of these. That is why adding npm-shaped controls would add
+more policy surface than protection.
+
+The two recommended adaptations have favorable cost-to-value ratios:
+
+- **Vendored provenance:** no runtime or build dependency, negligible ongoing
+  cost, and a direct audit trail for the only imported source. A digest check
+  should fail only when that file is intentionally reviewed and updated.
+- **Artifact-only live smoke:** reuses the existing temporary project,
+  installed package, hostile development-override checks, and clean-host
+  harness. Its release-time cost is seconds for `-n`/startup probes, plus any
+  already-authorized provider quota if the credentialed phase is included. It
+  catches a class of packaging errors that source regressions cannot.
+
+By contrast, a minimum-age database for one manually updated header would need
+timestamps and exception policy while providing no download-time enforcement.
+An ignore/allow-script layer would have nothing to inspect and could encourage
+confusing normal, reviewed OpenBSD port hooks with third-party package
+lifecycle execution. Both should remain explicit reconsideration triggers,
+not current implementation work.
+
+## Concrete release-policy shape
+
+Without adding a new dependency system, the eventual fugu policy should be:
+
+1. Every vendored source file names its upstream repository, immutable commit,
+   license, and digest; an intentional update changes those facts in the same
+   reviewed commit.
+2. Release source is produced only from clean committed Git state and accepted
+   by the ports checksum target.
+3. The package is built through the ports fake/package stages and its actual
+   linkage and packing list are checked.
+4. `make live-check` runs the installed front end from a newly created project
+   outside the source checkout, with development path overrides absent or
+   deliberately poisoned, and fails if any checkout resource is required.
+5. The privileged clean-host and ordinary-user credentialed phases remain
+   separate; this artifact smoke does not move network credentials into a root
+   process.
+6. Registry cooldowns and lifecycle-script policy become mandatory design
+   questions only if a future change introduces a registry resolver or foreign
+   install-time executable code.
+
+This preserves fugu's smaller supply chain instead of recreating Pi's larger
+one merely to apply Pi's mitigations.
blob - d2a72edf15a09d3b1f6c29f88d0235e2f1957082
blob + 001035f60bfd1222df5c9ec3cc0a4c23a32e90b1
--- handoff/behavior.md
+++ handoff/behavior.md
@@ -84,10 +84,14 @@ Transcript behavior:
   current assistant block.
 - Assistant text renders markdown: headings, fenced code blocks,
   bullet lists, inline code, bold/italic — via curses attributes.
-  Unmatched markers render literally. User text renders plain.
-- Tool calls appear as they run, visually distinct from prose (the
-  reference used a `tool:`-prefixed line per call; equivalent clarity
-  is required, exact dress free).
+  Unmatched markers render literally. Ordinary assistant text is light
+  grey when color is available; markdown-specific colors take precedence.
+  User text renders plain.
+- Tool calls appear as they run, indented and visually distinct from prose.
+  Each `tool:`-prefixed line includes a bounded, control-safe allowlist of
+  useful arguments: for example, a read's path and line window or a grep's
+  pattern and path. Bulk or secret-prone values such as file content, edit
+  replacements, HTTP headers/bodies, and subagent prompts are omitted.
 - A successful `edit` or `multi_edit` (one before/after diff across
   all applied pairs), or a `write` replacing an existing file,
   additionally renders as a unified-diff hunk — hunk header, a few
@@ -104,20 +108,23 @@ Transcript behavior:
   line. Presentation only — it never enters the provider projection
   or the journal's replayable context (I14).
 - Errors are visually distinct from assistant output.
-- Scrollback: page keys always; while scrolled away from the tail, the
-  status bar says so and how to return; new output does not yank the
-  view while the Owner is reading history unless they were at the tail.
+- Scrollback: page keys and the mouse wheel always; while scrolled away
+  from the tail, the status bar says so and how to return; new output does
+  not yank the view while the Owner is reading history unless they were at
+  the tail.
 
 Status bar: an idle/busy indicator (distinct states for a normal
 turn, a summarization, and an automatic retry — the retry state shows
 attempt count and pending delay, §8), the current input mode, and —
-once known — the running token usage for the session (input/output
-and, when the provider reports them, cache-read/cache-write counts).
-Once a usage report exists the bar also shows percent-of-window,
-computed from the last report's input tokens against the effective
-window (§3, /context); at or above 80% the field renders in a warning
-attribute, and the first crossing emits a one-line notice naming
-`/compact`.
+once known — `Context current/max N%` against the effective window (§3,
+`/context`). `current` is the latest committed Lead Generation's active
+context: Anthropic and Claude add uncached input, output, cache-read, and
+cache-created tokens; OpenAI adds prompt and completion tokens because its
+cached-token count is already a subset of prompt tokens. Ephemeral subagent
+usage is excluded from this display, while remaining part of machine-readable
+whole-Turn usage. Counts of at least 1,000 are rounded to the nearest thousand
+and shown with `k`. At or above 80% the field renders in a warning attribute,
+and the first crossing emits a one-line notice naming `/compact`.
 
 When a turn or summarization completes, the curses interface writes
 the terminal bell (BEL) so multiplexers and unfocused terminals can
@@ -128,8 +135,8 @@ Input editing is modal, vi-like, starting in insert mo
 
 - Either mode: `Enter` submits; `Tab`/`Shift-Tab` cycle slash-command
   completions when the line starts with `/` (built-ins first, then
-  skills alphabetically); `PgUp`/`PgDn` scroll the transcript;
-  `Up`/`Down` recall prompt history (this session); `Ctrl-L` repaints;
+  skills alphabetically); `PgUp`/`PgDn` and the mouse wheel scroll the
+  transcript; `Up`/`Down` recall prompt history (this session); `Ctrl-L` repaints;
   `Ctrl-C` clears a non-empty line, quits on an empty one; `Ctrl-D`
   quits on an empty line; `Esc` enters normal mode.
 - Normal mode: `h l 0 $ ^ w b e` motions; `i a A I O` (and `o` is
@@ -139,6 +146,12 @@ Input editing is modal, vi-like, starting in insert mo
   `J`/`K` scroll the transcript one line (allowed even mid-turn);
   `v` composes in the external editor (below).
 
+On taking the terminal the curses front end enables xterm basic mouse
+tracking with SGR coordinates and routes wheel reports to transcript
+scrollback; click and release reports are consumed without editing input.
+It disables mouse reporting on every survivable teardown and while an
+external editor owns the terminal. `Up`/`Down` remain prompt-history keys.
+
 Paste is atomic: on taking the terminal the curses front end enables
 xterm bracketed-paste mode (and disables it on every teardown path
 the process survives — pledge-violation death leaves the terminal
@@ -648,9 +661,10 @@ are the supported alternative.
 The conversation has one canonical in-memory shape; each route translates at
 its boundary. Direct routes provide model-listing calls to the picker; a
 subscription route contributes its configured model and does not issue a
-listing call. Usage (input/output tokens, plus cache-read/cache-write when
-reported) is surfaced to the UI. Provider errors reach the Owner without
-revealing credentials. A turn that errors mid-stream obeys §7.
+listing call. Lead usage becomes the UI's provider-aware context count;
+subagent usage remains only in whole-Turn accounting. Provider errors reach
+the Owner without revealing credentials. A turn that errors mid-stream obeys
+§7.
 
 Prompt caching: on the direct anthropic protocol every request — lead and
 subagent — marks cache breakpoints: `cache_control` ephemeral on the
blob - 49aba19b71cdb0eb2531d3682f2d15e5ad74f3b7
blob + f53727c56c505a66fcf3934d6fc5b9b0af9c46c4
--- handoff/verification.md
+++ handoff/verification.md
@@ -168,7 +168,8 @@ states the property, not just examples:
   Generations separately prove the Projection and cache cursor stay paired.
 - **Context meter:** the effective-window resolution order
   exercised (`context_limit`, provider-reported, compiled table,
-  unknown); `/context` leaves the journal byte-identical; the 80%
+  unknown); `/context` leaves the journal byte-identical; the status count
+  combines Lead cache and session tokens without subagent usage; the 80%
   gauge threshold and its single first-crossing notice.
 - **Notify:** bell emitted on turn completion with `notify yes`,
   absent with `notify no`, never in line or print mode.
blob - 3dd3451603c9981854b74ef0417e547ffc0083e0
blob + c3f2ea6f4cc01df501a740da95851e17c54bc553
--- regress/Makefile
+++ regress/Makefile
@@ -1,5 +1,5 @@
 SUBDIR=	agentcfg anthropic buf claude_subscription compaction conf deploy diff editor generation http imsgev journal json local_port log markdown \
-	model_window openai output print sandbox skills sse term tools turn \
+	model_window openai output print sandbox skills sse term tool_display tools turn \
 	turn_mechanics turn_txn ui_config web xmalloc
 
 .include <bsd.subdir.mk>
blob - f468e1ca79e277670ab24e661dc8fa3645ed129b
blob + 3c5f8488d910e615a68f0ea2110ab7eac0b1f7bf
--- regress/README
+++ regress/README
@@ -127,6 +127,8 @@ provider codec: anthropic		anthropic (builder + stream
 provider codec: openai			openai (builder, adversarial stream,
 					Bearer-auth full worker mesh)
 tools (behavior.md 5)			agentcfg (agent schema and routing);
+					tool_display (bounded, control-safe progress
+					summaries with secret-prone fields omitted);
 					tools (executors, caps, freshness,
 					NUL rejection, bounded background
 					draining, supervisor/liveness cleanup);
@@ -211,6 +213,7 @@ src/fugu-tool (tools.c, job.c,
 					CTX read/append, ephemeral agent loop)
 src/fugu/tooldefs.c			turn (tools offered per gates,
 					the skill tool)
+src/fugu/tool_display.c		tool_display; turn (line-mode wiring)
 src/fugu/skills.c			skills (load/parse); turn (dispatch)
 src/fugu/output.c			output, print
 src/fugu-web (fetch.c, main.c)		web (hermetic TLS flows); sandbox
blob - 37bf30d1809764ae9daa9373ae61d981ff86ae69
blob + 798f9e90dbe3a9a6332e8b66247410dc489e689f
--- regress/editor/editor_test.c
+++ regress/editor/editor_test.c
@@ -338,5 +338,15 @@ main(void)
 	CHECK(ed_feed(&e, K_PGUP, 0, NULL, 0) == R_PAGE_UP);
 	ed_free(&e);
 
+	/* Mouse-wheel events scroll the transcript without recalling history. */
+	ed_init(&e);
+	ed_history_add(&e, "old command", 11);
+	type(&e, "draft");
+	CHECK(ed_feed(&e, K_WHEEL_UP, 0, NULL, 0) == R_SCROLL_LINE_UP);
+	CHECK(text_is(&e, "draft"));
+	CHECK(ed_feed(&e, K_WHEEL_DOWN, 0, NULL, 0) == R_SCROLL_LINE_DOWN);
+	CHECK(text_is(&e, "draft"));
+	ed_free(&e);
+
 	REGRESS_END();
 }
blob - c40e8d28c1b55f1f46af4bb9b948aeba1739b689
blob + b562dc7a99bb00a01cdce4f625ba2c7ffab55a09
--- regress/term/term_test.c
+++ regress/term/term_test.c
@@ -65,6 +65,42 @@ main(void)
 	CHECK(one_key("\x1b[6~", 4, NULL) == K_PGDN);
 	CHECK(one_key("\x1b[Z", 3, NULL) == K_BTAB);
 
+	/* ---- SGR mouse wheel: distinct from editor-history arrows ---- */
+	CHECK(one_key("\x1b[<64;12;7M", sizeof("\x1b[<64;12;7M") - 1,
+	    NULL) == K_WHEEL_UP);
+	CHECK(one_key("\x1b[<65;12;7M", sizeof("\x1b[<65;12;7M") - 1,
+	    NULL) == K_WHEEL_DOWN);
+	/* Ctrl-modified wheel still uses the low button bits. */
+	CHECK(one_key("\x1b[<80;12;7M", sizeof("\x1b[<80;12;7M") - 1,
+	    NULL) == K_WHEEL_UP);
+	{
+		struct term_input	ti;
+		struct ti_key		k;
+
+		ti_init(&ti);
+		ti_feed(&ti, (const unsigned char *)"\x1b[<64;12", 8);
+		CHECK(ti_next(&ti, &k) == TI_NONE);
+		ti_feed(&ti, (const unsigned char *)";7M", 3);
+		CHECK(ti_next(&ti, &k) == TI_KEY && k.key == K_WHEEL_UP);
+		ti_free(&ti);
+	}
+	/* Unused mouse events are consumed whole; coordinates never become
+	 * editor input, and the next ordinary key remains available. */
+	{
+		struct term_input	ti;
+		struct ti_key		k;
+
+		ti_init(&ti);
+		ti_feed(&ti, (const unsigned char *)"\x1b[<0;12;7Mx",
+		    sizeof("\x1b[<0;12;7Mx") - 1);
+		CHECK(ti_next(&ti, &k) == TI_KEY && k.key == K_CHAR &&
+		    k.cp == 'x');
+		CHECK(ti_next(&ti, &k) == TI_NONE);
+		ti_free(&ti);
+	}
+	CHECK(one_key("\x1b[<64;12;7m", sizeof("\x1b[<64;12;7m") - 1,
+	    NULL) == -1);
+
 	/* ---- SS3 arrows ---- */
 	CHECK(one_key("\x1bOA", 3, NULL) == K_UP);
 	CHECK(one_key("\x1bOF", 3, NULL) == K_END);
blob - /dev/null
blob + 272e18743e40ede8d02a30b0098eefae861bf99a (mode 644)
--- /dev/null
+++ regress/tool_display/Makefile
@@ -0,0 +1,7 @@
+PROG=	tool_display_test
+SRCS=	tool_display_test.c tool_display.c json.c buf.c log.c xmalloc.c
+
+CFLAGS+=	-I${.CURDIR}/../../src/fugu
+.PATH:		${.CURDIR}/../../src/fugu
+
+.include <bsd.regress.mk>
blob - /dev/null
blob + 2fee78d2b5bbccbdb5ea42da729ae616a7d0f7b2 (mode 644)
--- /dev/null
+++ regress/tool_display/tool_display_test.c
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <string.h>
+
+#include "buf.h"
+#include "tool_display.h"
+#include "regress.h"
+
+static int
+format_is(const char *name, const char *input, const char *want)
+{
+	struct buf	out;
+	int		ok;
+
+	buf_init(&out);
+	tool_display_format(&out, name, input, strlen(input));
+	ok = out.len == strlen(want) &&
+	    memcmp(out.data, want, out.len) == 0 &&
+	    out.len <= TOOL_DISPLAY_MAX &&
+	    memchr(out.data, '\n', out.len) == NULL &&
+	    memchr(out.data, '\0', out.len) == NULL;
+	buf_free(&out);
+	return (ok);
+}
+
+static void
+test_read_and_search(void)
+{
+	CHECK(format_is("read", "{\"path\":\"src/fugu/coord.c\","
+	    "\"offset\":1017,\"limit\":20,\"content\":\"not shown\"}",
+	    "read path=\"src/fugu/coord.c\" offset=1017 limit=20"));
+	CHECK(format_is("grep", "{\"pattern\":\"tool:\\nread\","
+	    "\"path\":\"src/fugu\"}",
+	    "grep pattern=\"tool:\\nread\" path=\"src/fugu\""));
+	CHECK(format_is("find", "{\"path\":\"src\",\"name\":\"*.c\"}",
+	    "find path=\"src\" name=\"*.c\""));
+	CHECK(format_is("ls", "{}", "ls"));
+}
+
+static void
+test_safe_allowlist(void)
+{
+	CHECK(format_is("edit", "{\"path\":\"notes.txt\","
+	    "\"old_string\":\"private old text\","
+	    "\"new_string\":\"private new text\"}",
+	    "edit path=\"notes.txt\""));
+	CHECK(format_is("http_request", "{\"method\":\"POST\","
+	    "\"url\":\"https://example.com/v1\","
+	    "\"headers\":{\"Authorization\":\"secret\"},"
+	    "\"body\":\"private body\"}",
+	    "http_request method=\"POST\" url=\"https://example.com/v1\""));
+	CHECK(format_is("agent", "{\"label\":\"reader\","
+	    "\"model\":\"fast\",\"prompt\":\"private prompt\"}",
+	    "agent label=\"reader\" model=\"fast\""));
+	CHECK(format_is("shell", "{\"command\":\"printf 'a\\nb'\"}",
+	    "shell command=\"printf 'a\\nb'\""));
+}
+
+static void
+test_hostile_and_invalid_input(void)
+{
+	struct buf	input, out;
+	int		i;
+
+	CHECK(format_is("read", "{\"path\":\"a\\u0000b\\tc\"}",
+	    "read path=\"a\\x00b\\tc\""));
+	CHECK(format_is("read", "not json", "read"));
+	CHECK(format_is("future_tool", "{\"secret\":\"not shown\"}",
+	    "future_tool"));
+
+	buf_init(&input);
+	buf_addstr(&input, "{\"path\":\"");
+	for (i = 0; i < 1000; i++)
+		buf_addc(&input, 'x');
+	buf_addstr(&input, "\"}");
+	buf_init(&out);
+	tool_display_format(&out, "read", input.data, input.len);
+	CHECK(out.len <= TOOL_DISPLAY_MAX);
+	CHECK(memmem(out.data, out.len, "...\"", 4) != NULL);
+	CHECK(memchr(out.data, '\n', out.len) == NULL);
+	CHECK(memchr(out.data, '\0', out.len) == NULL);
+	buf_free(&out);
+	buf_free(&input);
+}
+
+int
+main(void)
+{
+	test_read_and_search();
+	test_safe_allowlist();
+	test_hostile_and_invalid_input();
+	REGRESS_END();
+}
blob - 856be903b94c0ed52260d77fd10f15845994f437
blob + 1d654133634f2852306ac68ef95b3652f76f93b3
--- regress/turn/run.sh
+++ regress/turn/run.sh
@@ -490,6 +490,9 @@ ok "tool loop: final assistant text on stdout" $?
 # the tool's output was fed back to the provider in the 2nd request
 grep -q "tool-ran-ok" "$dir/reqout"
 ok "tool loop: the tool result reached the next request" $?
+grep -Fq 'fugu: shell command="if ( exec 4>&3 ) 2>/dev/null; then n=1; else n=0; fi; echo FD3=$n; echo tool-ran-ok"' \
+    "$dir/errout"
+ok "tool loop: progress shows the bounded command arguments" $?
 # the coordinator control fd did not leak into the tool subprocess (I10):
 # the computed FD3=0 marks it closed; FD3=1 would mean a leak
 grep -q "FD3=0" "$dir/reqout"
@@ -986,16 +989,24 @@ stop_stub
 # the request, the journal commits it) and /quit must exit cleanly.
 if [ -x "$obj/ptydrive" ]; then
 	rm -rf "$HOME/.fugu"
-	start_stub "OK"
+	start_stub "CONTEXT"
 	write_conf "$stubport"
 	printf 'hello\r/quit\r' | TERM=vt100 HOME=$HOME \
 	    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
 	    FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \
-	    "$obj/ptydrive" "$fugu" >/dev/null 2>&1
+	    "$obj/ptydrive" "$fugu" >"$dir/cursesout" 2>&1
 	rc=$?
 	ok "curses: clean exit on /quit" $([ "$rc" -eq 0 ]; echo $?)
+	esc=$(printf '\033')
+	grep -a -Fq "${esc}[?1000h" "$dir/cursesout" &&
+	    grep -a -Fq "${esc}[?1006h" "$dir/cursesout" &&
+	    grep -a -Fq "${esc}[?1006l" "$dir/cursesout" &&
+	    grep -a -Fq "${esc}[?1000l" "$dir/cursesout"
+	ok "curses: mouse reporting is enabled and restored" $?
 	grep -q "hello" "$dir/reqout" 2>/dev/null
 	ok "curses: the typed prompt reached the provider" $?
+	grep -a -Fq "Context 46k/200k 23%" "$dir/cursesout"
+	ok "curses: status shows Lead cache plus session context" $?
 	n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null || echo 0)
 	ok "curses: the turn committed to the journal" \
 	    $([ "$n" -ge 1 ]; echo $?)
blob - 4b5cb3e5af223e4dba2ae36ed27cd3de82c39cc7
blob + 1caeb1d9d7fc99fd6cb999e6dfc32268d156f5bd
--- regress/turn/stub.c
+++ regress/turn/stub.c
@@ -18,7 +18,8 @@
  * Hermetic Anthropic Messages stub for the turn suite: over TLS, it
  * checks the request carried the x-api-key header, then serves a
  * canned chunked SSE stream chosen per accepted connection (in argv
- * order).  Scenarios: OK (a two-delta text reply), ERROR (an SSE
+ * order).  Scenarios: OK (a two-delta text reply), CONTEXT (a reply with
+ * distinct uncached/cache-read/cache-created/output usage), ERROR (an SSE
  * error event), HTTP500 (a non-2xx before any stream), HTTP401 (an
  * Anthropic-style JSON error body), RETRY503/RETRY429 (retryable
  * statuses, 429 carrying Retry-After: 2), EMPTY (a well-formed
@@ -100,6 +101,27 @@ static const char sse_ok[] =
 "event: message_stop\n"
 "data: {\"type\":\"message_stop\"}\n\n";
 
+/* Distinct counters make the context sum observable: 11k+23k+5k+7k=46k. */
+static const char sse_context[] =
+"event: message_start\n"
+"data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+    "{\"input_tokens\":11000,\"output_tokens\":0,"
+    "\"cache_read_input_tokens\":23000,"
+    "\"cache_creation_input_tokens\":5000}}}\n\n"
+"event: content_block_start\n"
+"data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n"
+"event: content_block_delta\n"
+"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"text_delta\",\"text\":\"Context reply.\"}}\n\n"
+"event: content_block_stop\n"
+"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+"event: message_delta\n"
+"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"end_turn\"},\"usage\":{\"output_tokens\":7000}}\n\n"
+"event: message_stop\n"
+"data: {\"type\":\"message_stop\"}\n\n";
+
 static const char sse_error[] =
 "event: error\n"
 "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\","
@@ -1425,6 +1447,12 @@ main(int argc, char *argv[])
 				    strlen(body_ok));
 				write_chunk(conn, sse_ok, strlen(sse_ok));
 				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "CONTEXT") == 0) {
+				(void)tls_write_all(conn, body_ok,
+				    strlen(body_ok));
+				write_chunk(conn, sse_context,
+				    strlen(sse_context));
+				(void)tls_write_all(conn, "0\r\n\r\n", 5);
 			} else if (strcmp(argv[i], "ERROR") == 0) {
 				(void)tls_write_all(conn, body_ok,
 				    strlen(body_ok));
blob - 66f185c8b868152db5385174305a82e52e7dc40b
blob + 270f362460e38a84a8a0639ff31e28dcc042408a
--- src/fugu/Makefile
+++ src/fugu/Makefile
@@ -1,6 +1,6 @@
 PROG=	fugu
 SRCS=	main.c coord.c priv.c conf.c parse.y \
-	journal.c sysprompt.c tooldefs.c agentcfg.c skills.c output.c \
+	journal.c sysprompt.c tooldefs.c tool_display.c agentcfg.c skills.c output.c \
 	turn_txn.c turn_mechanics.c anthropic_req.c claude_sub_req.c \
 	claude_sub_profile.c openai_req.c generation.c compaction.c msg.c \
 	json.c utf8.c model_window.c buf.c imsgev.c log.c xmalloc.c
blob - a3ccad20603d14c4e15917c4d7a6cfd83641b3d5
blob + ded274898ad56b8359bd113ed666fd6b5169d436
--- src/fugu/coord.c
+++ src/fugu/coord.c
@@ -54,6 +54,7 @@
 #include "sysprompt.h"
 #include "skills.h"
 #include "tooldefs.h"
+#include "tool_display.h"
 #include "agentcfg.h"
 #include "turn_mechanics.h"
 #include "turn_txn.h"
@@ -208,7 +209,9 @@ struct coord {
 
 	/* accepted Generation views used by the existing presentation/tool seams */
 	struct buf		 text;
-	struct a_usage		 usage;
+	struct a_usage		 lead_usage;	/* latest Lead Generation only */
+	int			 have_context;	/* published main-session context */
+	int64_t			 context_tokens;
 	struct tool_call	 tcalls[MAX_TOOL_CALLS];
 	int			 ntcalls;
 	char			 stop_reason[FUGU_STOP_REASON_MAX];
@@ -1004,12 +1007,21 @@ emit_answer(struct coord *c)
 }
 
 static void
-emit_tool(struct coord *c, const char *name)
+emit_tool(struct coord *c, const struct tool_call *tc)
 {
+	struct buf	display;
+
+	buf_init(&display);
+	tool_display_format(&display, tc->name, tc->input.data, tc->input.len);
 	if (c->ui)
-		ui_send(c, FUGU_IMSG_UI_TOOL, name, strlen(name));
-	else
-		fprintf(stderr, "fugu: %s\n", name);		/* terse (S1) */
+		ui_send(c, FUGU_IMSG_UI_TOOL, display.data, display.len);
+	else {
+		fputs("fugu: ", stderr);
+		if (display.len > 0)
+			fwrite(display.data, 1, display.len, stderr);
+		fputc('\n', stderr);
+	}
+	buf_free(&display);
 }
 
 /* A diff can carry very long lines (the line count is capped, not the
@@ -1074,21 +1086,6 @@ emit_msg(struct coord *c, int uitype, FILE *fallback, 
 /* System feedback (stdout / UI_NOTE). */
 #define emit_note(c, ...)  emit_msg((c), FUGU_IMSG_UI_NOTE, stdout, __VA_ARGS__)
 
-/* Push a status-bar update to the front end (behavior.md 2.1). */
-static void
-send_status(struct coord *c, int state)
-{
-	struct ui_status	s;
-
-	if (!c->ui)
-		return;
-	memset(&s, 0, sizeof(s));
-	s.state = state;
-	s.have_usage = (c->usage.input_tokens > 0 || c->usage.output_tokens > 0);
-	s.usage = c->usage;
-	ui_send(c, FUGU_IMSG_UI_STATUS, &s, sizeof(s));
-}
-
 static int64_t	effective_window(struct coord *);	/* defined below */
 
 static int
@@ -1116,19 +1113,88 @@ usage_add(struct a_usage *dst, const struct a_usage *s
 	return (0);
 }
 
+/*
+ * Provider usage does not have one cross-provider cache convention.
+ * Anthropic (including the Claude subscription route) reports uncached,
+ * cache-read, and cache-created input as disjoint counters.  OpenAI's
+ * prompt_tokens already includes cached_tokens, which is only a detail of
+ * that total.  Output is part of the context after the Generation settles.
+ */
+static int64_t
+lead_context_tokens(const struct coord *c)
+{
+	const struct a_usage	*u = &c->lead_usage;
+	int64_t		 total;
+
+	total = u->input_tokens + u->output_tokens;
+	if (c->conf->provider_type != PROVIDER_OPENAI)
+		total += u->cache_read + u->cache_write;
+	return (total);
+}
+
+static void
+context_publish(struct coord *c)
+{
+	const struct a_usage	*u = &c->lead_usage;
+
+	c->have_context = (u->input_tokens > 0 || u->output_tokens > 0 ||
+	    u->cache_read > 0 || u->cache_write > 0);
+	c->context_tokens = c->have_context ? lead_context_tokens(c) : 0;
+}
+
+static void
+context_invalidate(struct coord *c)
+{
+	c->have_context = 0;
+	c->context_tokens = 0;
+}
+
 static int
-usage_account(struct coord *c, const struct a_usage *u)
+lead_usage_account(struct coord *c, const struct a_usage *u)
 {
-	struct a_usage session = c->usage;
+	struct a_usage lead = c->lead_usage;
 	struct a_usage turn = c->turn_usage;
 
-	if (usage_add(&session, u) == -1 || usage_add(&turn, u) == -1)
+	if (usage_add(&lead, u) == -1 || usage_add(&turn, u) == -1)
 		return (-1);
-	c->usage = session;
+	c->lead_usage = lead;
 	c->turn_usage = turn;
 	return (0);
 }
 
+/* Subagent usage remains part of whole-Turn accounting, never Lead context. */
+static int
+subagent_usage_account(struct coord *c, const struct a_usage *u)
+{
+	struct a_usage turn = c->turn_usage;
+
+	if (usage_add(&turn, u) == -1)
+		return (-1);
+	c->turn_usage = turn;
+	return (0);
+}
+
+static void
+status_fill(struct coord *c, struct ui_status *s, int state)
+{
+	memset(s, 0, sizeof(*s));
+	s->state = state;
+	s->have_context = c->have_context;
+	s->context_tokens = c->context_tokens;
+}
+
+/* Push a status-bar update to the front end (behavior.md 2.1). */
+static void
+send_status(struct coord *c, int state)
+{
+	struct ui_status s;
+
+	if (!c->ui)
+		return;
+	status_fill(c, &s, state);
+	ui_send(c, FUGU_IMSG_UI_STATUS, &s, sizeof(s));
+}
+
 /*
  * One typed configuration transaction: fixed settings, bounded Skill
  * names, then the terminal frame that makes the snapshot visible.  Keeping
@@ -1286,7 +1352,7 @@ stream_event(struct coord *c, struct imsg *imsg)
 		au.cache_read = ev.usage.cache_read;
 		au.cache_write = ev.usage.cache_write;
 		if (!c->turn_data_exceeded && !c->turn_usage_exceeded) {
-			if (usage_account(c, &au) == -1) {
+			if (lead_usage_account(c, &au) == -1) {
 				if (c->turn_generation_io != NULL)
 					c->turn_usage_exceeded = 1;
 				else
@@ -1303,10 +1369,7 @@ stream_event(struct coord *c, struct imsg *imsg)
 
 			ui_send(c, FUGU_IMSG_UI_NOTE, ev.payload.data,
 			    ev.payload.len);
-			memset(&s, 0, sizeof(s));
-			s.state = UI_RETRY;
-			s.have_usage = 1;
-			s.usage = c->usage;
+			status_fill(c, &s, UI_RETRY);
 			ui_send(c, FUGU_IMSG_UI_STATUS, &s, sizeof(s));
 		} else
 			fprintf(stderr, "fugu: %.*s\n", (int)ev.payload.len,
@@ -1662,6 +1725,7 @@ ui_event(struct coord *c, struct imsg *imsg)
 		free(c->conf->model);
 		c->conf->model = xstrdup(id);
 		turn_txn_cache_reset(c->turn);	/* the cache starts cold (8) */
+		context_invalidate(c);		/* tokenization/provider changed */
 		send_ui_config(c);		/* the status bar shows it */
 		emit_note(c, "model: %s", id);
 		break;
@@ -2437,7 +2501,7 @@ agent_dispatch(int fd, short event, void *arg)
 			if (turn_tool_results_add_usage(c->turn_tool_results,
 			    &gu) == -1)
 				c->turn_tool_results_rejected = 1;
-			observer_rc = usage_account(c, &u);
+			observer_rc = subagent_usage_account(c, &u);
 			if (observer_rc == -1)
 				c->turn_usage_exceeded = 1;
 			else
@@ -2798,7 +2862,7 @@ lead_generate(void *arg, const struct turn_generation_
 
 	reset_tcalls(c);
 	buf_reset(&c->text);
-	memset(&c->usage, 0, sizeof(c->usage));
+	memset(&c->lead_usage, 0, sizeof(c->lead_usage));
 	c->stop_reason[0] = '\0';
 	c->err[0] = '\0';
 	c->generation_terminal = 0;
@@ -2859,7 +2923,7 @@ pump_generation(struct coord *c, size_t data_available
 	memset(result, 0, sizeof(*result));
 	reset_tcalls(c);
 	buf_reset(&c->text);
-	memset(&c->usage, 0, sizeof(c->usage));
+	memset(&c->lead_usage, 0, sizeof(c->lead_usage));
 	c->stop_reason[0] = '\0';
 	c->err[0] = '\0';
 	c->generation = generation_new(data_available);
@@ -3423,7 +3487,7 @@ lead_execute(void *arg, const struct turn_tool_batch *
 		call = &batch->calls[i];
 		c->turn_tool_calls++;
 		machine_tool_call(c, tc);
-		emit_tool(c, tc->name);				/* terse (S1) */
+		emit_tool(c, tc);
 		if (strcmp(tc->name, "agent") == 0) {
 			if (!tc->result_ready)
 				agent_call_error(tc, "agent: no result");
@@ -3548,7 +3612,7 @@ run_turn(struct coord *c, const char *prompt, size_t p
 	msg_add_text(um, prompt, promptlen);
 
 	memset(&c->turn_usage, 0, sizeof(c->turn_usage));
-	memset(&c->usage, 0, sizeof(c->usage));
+	memset(&c->lead_usage, 0, sizeof(c->lead_usage));
 	c->turn_tool_calls = 0;
 	c->turn_data = promptlen;
 	c->turn_data_exceeded = 0;
@@ -3602,6 +3666,7 @@ run_turn(struct coord *c, const char *prompt, size_t p
 			    "turn abandoned by session switch"));
 		if (gate != COMMIT_GATE_COMMITTED)
 			return (cancel_turn(c));
+		context_publish(c);
 		emit_answer(c);
 		machine_finish(c, 0);
 		return (0);
@@ -3612,6 +3677,7 @@ run_turn(struct coord *c, const char *prompt, size_t p
 			    "turn abandoned by session switch"));
 		if (gate != COMMIT_GATE_COMMITTED)
 			return (cancel_turn(c));
+		context_publish(c);
 		emit_error(c, "fugu: reached the %d-step tool limit for one turn",
 		    TURN_ITER_MAX);
 		machine_error(c, "tool-loop iteration limit reached");
@@ -3670,6 +3736,7 @@ cmd_clear(struct coord *c)
 	turn_txn_clear(c->turn, contextp);
 	buf_reset(&c->compaction);		/* a reset voids the summary */
 	rebuild_system(c);			/* drop the summary either way */
+	context_invalidate(c);
 	if (c->ui)
 		ui_send(c, FUGU_IMSG_UI_CLEARED, NULL, 0);
 	emit_note(c, "context cleared");
@@ -3774,6 +3841,7 @@ cmd_compact(struct coord *c)
 	buf_reset(&c->compaction);
 	buf_add(&c->compaction, c->text.data, c->text.len);
 	rebuild_system(c);
+	context_invalidate(c);
 	if (c->ui)
 		ui_send(c, FUGU_IMSG_UI_CLEARED, NULL, 0);
 	emit_note(c, "conversation compacted (~%lld tokens of notes)",
blob - 2811160f11ee550272b36ff18b2e241e843b186d
blob + c842be5413d99c5bbb441fd8cb9ef666c916c8ec
--- src/fugu/fugu.1
+++ src/fugu/fugu.1
@@ -142,7 +142,15 @@ Assistant markdown headings, lists, fenced code, empha
 are rendered with terminal attributes; user messages remain plain text.
 Tool calls, errors, and edit diffs are visually distinct.
 The status bar identifies idle, active-turn, summarization, and retry states,
-and shows token usage and effective-context-window use when known.
+and shows
+.Dq Context current/max N%
+when known.
+The current count combines cache and session tokens from the main
+conversation's latest committed generation; ephemeral subagent usage is
+excluded.
+Counts of at least 1,000 are rounded to the nearest thousand and carry a
+.Dq k
+suffix.
 At 80 percent of the effective window it warns once and names
 .Cm /compact .
 .
@@ -160,6 +168,8 @@ Cycle built-in slash-command and skill completions whe
 Recall prompt history.
 .It Ic PageUp , PageDown
 Scroll the transcript.
+.It Ic Mouse wheel
+Scroll the transcript one line per report without changing prompt history.
 .It Ic Ctrl-L
 Repaint the display.
 .It Ic Ctrl-C
blob - 6e230ba39625996c74c90c4214ffc8af98c7f310
blob + 1b7a16b8769e9b408730b8ed5e4c7c729edf5313
--- src/fugu/fugu.conf.5
+++ src/fugu/fugu.conf.5
@@ -256,10 +256,30 @@ The special pattern
 .Sq *
 matches any host.
 Host matching is case-insensitive.
+The default is to allow no destinations and not offer the
+.Cm http_request
+tool.
+This does not affect
+.Cm web_search
+or
+.Cm web_fetch .
 .It Cm http_block Ar patterns
 Refuse destinations matching patterns in the same form as
 .Cm http_allow .
 A block always overrides an allow.
+The default is to apply no block patterns.
+Specifying
+.Cm http_block
+without
+.Cm http_allow
+does not offer the
+.Cm http_request
+tool.
+Every destination must still match
+.Cm http_allow
+and pass the built-in URL and address checks; resolved private, loopback,
+link-local, and other non-public addresses are refused independently of these
+patterns.
 .It Cm max_bg_jobs Ar number
 Set the maximum number of concurrent background shell jobs.
 The value must be between 0 and 256 and defaults to 4.
blob - 33b066010d4001607e945821266929cb6d00ae58
blob + 6c69d306111f7d24e254e16cf6e009bf954d370b
--- src/fugu/proto.h
+++ src/fugu/proto.h
@@ -423,8 +423,8 @@ struct ui_status {
 	int		state;		/* enum ui_state */
 	int		attempt;	/* retry attempt number (UI_RETRY) */
 	int		delay_ms;	/* pending retry delay (UI_RETRY) */
-	int		have_usage;	/* usage fields are meaningful */
-	struct a_usage	usage;		/* running token counts */
+	int		have_context;	/* context_tokens is meaningful */
+	int64_t		context_tokens;	/* Lead context only; excludes subagents */
 };
 
 /* A tool_use block opening; the id and name follow as "id\0name". */
blob - /dev/null
blob + 70688281e28cba8bd06ce1b47a11dea3791b2c43 (mode 644)
--- /dev/null
+++ src/fugu/tool_display.c
@@ -0,0 +1,229 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "buf.h"
+#include "json.h"
+#include "tool_display.h"
+
+#define DISPLAY_NAME_MAX	64
+#define DISPLAY_VALUE_MAX	160
+#define DISPLAY_FIELDS_MAX	3
+
+enum display_type { DISPLAY_STRING, DISPLAY_NUMBER };
+
+struct display_field {
+	const char	*name;
+	int		 type;
+};
+
+struct display_tool {
+	const char		*name;
+	struct display_field	 fields[DISPLAY_FIELDS_MAX];
+};
+
+/*
+ * This is deliberately an allowlist.  Locator and action fields explain
+ * what ran; bulk or secret-prone fields (content, replacements, headers,
+ * bodies, and prompts) never become presentation text.
+ */
+static const struct display_tool tools[] = {
+	{ "read", {
+	    { "path", DISPLAY_STRING },
+	    { "offset", DISPLAY_NUMBER },
+	    { "limit", DISPLAY_NUMBER } } },
+	{ "write", { { "path", DISPLAY_STRING } } },
+	{ "edit", { { "path", DISPLAY_STRING } } },
+	{ "multi_edit", { { "path", DISPLAY_STRING } } },
+	{ "shell", { { "command", DISPLAY_STRING } } },
+	{ "grep", {
+	    { "pattern", DISPLAY_STRING },
+	    { "path", DISPLAY_STRING } } },
+	{ "find", {
+	    { "path", DISPLAY_STRING },
+	    { "name", DISPLAY_STRING } } },
+	{ "ls", { { "path", DISPLAY_STRING } } },
+	{ "web_search", { { "query", DISPLAY_STRING } } },
+	{ "web_fetch", { { "url", DISPLAY_STRING } } },
+	{ "http_request", {
+	    { "method", DISPLAY_STRING },
+	    { "url", DISPLAY_STRING } } },
+	{ "shell_bg", { { "command", DISPLAY_STRING } } },
+	{ "shell_output", { { "id", DISPLAY_NUMBER } } },
+	{ "shell_kill", { { "id", DISPLAY_NUMBER } } },
+	{ "skill", { { "name", DISPLAY_STRING } } },
+	{ "agent", {
+	    { "label", DISPLAY_STRING },
+	    { "model", DISPLAY_STRING } } }
+};
+
+static void
+add_tool_name(struct buf *out, const char *name)
+{
+	size_t	i;
+
+	if (name == NULL || name[0] == '\0') {
+		buf_addstr(out, "tool");
+		return;
+	}
+	for (i = 0; name[i] != '\0' && i < DISPLAY_NAME_MAX; i++) {
+		unsigned char c = (unsigned char)name[i];
+
+		if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+		    (c >= '0' && c <= '9') || c == '_' || c == '-')
+			buf_addc(out, c);
+		else
+			buf_addc(out, '?');
+	}
+	if (name[i] != '\0')
+		buf_addstr(out, "...");
+}
+
+static size_t
+utf8_len(unsigned char c)
+{
+	if (c < 0x80)
+		return (1);
+	if ((c & 0xe0) == 0xc0)
+		return (2);
+	if ((c & 0xf0) == 0xe0)
+		return (3);
+	return (4);
+}
+
+/* Add a decoded JSON string without allowing terminal controls or new lines. */
+static void
+add_quoted(struct buf *out, const unsigned char *s, size_t len)
+{
+	static const char hex[] = "0123456789abcdef";
+	size_t		  i = 0, shown = 0;
+
+	buf_addc(out, '"');
+	while (i < len) {
+		unsigned char	c = s[i];
+		const char	*escaped = NULL;
+		size_t		 n = 1, add = 1;
+
+		switch (c) {
+		case '"': escaped = "\\\""; add = 2; break;
+		case '\\': escaped = "\\\\"; add = 2; break;
+		case '\n': escaped = "\\n"; add = 2; break;
+		case '\r': escaped = "\\r"; add = 2; break;
+		case '\t': escaped = "\\t"; add = 2; break;
+		default:
+			if (c < 0x20 || c == 0x7f)
+				add = 4;
+			else if (c >= 0x80)
+				n = add = utf8_len(c);
+			break;
+		}
+		if (shown + add > DISPLAY_VALUE_MAX)
+			break;
+		if (escaped != NULL)
+			buf_add(out, escaped, add);
+		else if (c < 0x20 || c == 0x7f) {
+			buf_addstr(out, "\\x");
+			buf_addc(out, hex[c >> 4]);
+			buf_addc(out, hex[c & 0x0f]);
+		} else
+			buf_add(out, s + i, n);
+		shown += add;
+		i += n;
+	}
+	if (i < len)
+		buf_addstr(out, "...");
+	buf_addc(out, '"');
+}
+
+static const struct display_tool *
+find_tool(const char *name)
+{
+	size_t	i;
+
+	if (name == NULL)
+		return (NULL);
+	for (i = 0; i < sizeof(tools) / sizeof(tools[0]); i++)
+		if (strcmp(name, tools[i].name) == 0)
+			return (&tools[i]);
+	return (NULL);
+}
+
+static void
+add_field(struct buf *out, const struct json *j, int root,
+    const struct display_field *field)
+{
+	char		*s;
+	size_t		 len;
+	int64_t		 number;
+	int		 token;
+
+	if (field->name == NULL ||
+	    (token = json_obj_get(j, root, field->name)) == -1)
+		return;
+	if (field->type == DISPLAY_STRING) {
+		if ((s = json_get_str(j, token, &len)) == NULL)
+			return;
+		buf_addc(out, ' ');
+		buf_addstr(out, field->name);
+		buf_addc(out, '=');
+		add_quoted(out, (const unsigned char *)s, len);
+		free(s);
+	} else {
+		if (json_get_num(j, token, &number) == -1)
+			return;
+		buf_addc(out, ' ');
+		buf_addstr(out, field->name);
+		buf_addf(out, "=%lld", (long long)number);
+	}
+}
+
+void
+tool_display_format(struct buf *out, const char *name, const void *input,
+    size_t inputlen)
+{
+	const struct display_tool	*tool;
+	struct buf			 summary;
+	struct json			 json;
+	int				 root;
+	size_t				 i;
+
+	buf_reset(out);
+	buf_init(&summary);
+	add_tool_name(&summary, name);
+	tool = find_tool(name);
+	if (tool == NULL || input == NULL || inputlen == 0 ||
+	    json_parse(&json, input, inputlen, 0) == -1)
+		goto done;
+	root = json_root(&json);
+	if (json_is_object(&json, root))
+		for (i = 0; i < DISPLAY_FIELDS_MAX; i++)
+			add_field(&summary, &json, root, &tool->fields[i]);
+	json_done(&json);
+
+done:
+	/* The allowlist and per-value cap should fit; fail terse if they drift. */
+	if (summary.len > TOOL_DISPLAY_MAX) {
+		buf_reset(&summary);
+		add_tool_name(&summary, name);
+	}
+	buf_add(out, summary.data, summary.len);
+	buf_free(&summary);
+}
blob - /dev/null
blob + ccab4611f2c3f1fd53fcee6e2068c961970d39bb (mode 644)
--- /dev/null
+++ src/fugu/tool_display.h
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef TOOL_DISPLAY_H
+#define TOOL_DISPLAY_H
+
+#include <sys/types.h>
+
+#include "buf.h"
+
+/* One bounded, single-line tool-call summary for stderr or the transcript. */
+#define TOOL_DISPLAY_MAX	512
+
+void	tool_display_format(struct buf *, const char *, const void *, size_t);
+
+#endif /* TOOL_DISPLAY_H */
blob - 67abed12fae8a686c29bd7db01941150df10884b
blob + 5409a30504055bbd8724f303b025b81677ac02d2
--- src/fugu-tty/editor.c
+++ src/fugu-tty/editor.c
@@ -586,6 +586,10 @@ feed_insert(struct editor *e, enum ed_key k, uint32_t 
 	case K_BTAB:
 		do_complete(e, 0);
 		return (R_NONE);
+	case K_WHEEL_UP:
+		return (R_SCROLL_LINE_UP);
+	case K_WHEEL_DOWN:
+		return (R_SCROLL_LINE_DOWN);
 	case K_PGUP:
 		return (R_PAGE_UP);
 	case K_PGDN:
@@ -637,6 +641,10 @@ feed_normal(struct editor *e, enum ed_key k, uint32_t 
 		case K_DOWN:
 			recall_next(e);
 			return (R_NONE);
+		case K_WHEEL_UP:
+			return (R_SCROLL_LINE_UP);
+		case K_WHEEL_DOWN:
+			return (R_SCROLL_LINE_DOWN);
 		case K_PGUP:
 			return (R_PAGE_UP);
 		case K_PGDN:
blob - 90a435e08fdadb892bf4903e1095c85f24c25c06
blob + e1002aeb280f617e8da7ed1cef8f8a3bc4fcd134
--- src/fugu-tty/editor.h
+++ src/fugu-tty/editor.h
@@ -56,6 +56,8 @@ enum ed_key {
 	K_END,
 	K_PGUP,
 	K_PGDN,
+	K_WHEEL_UP,
+	K_WHEEL_DOWN,
 	K_CTRL_C,
 	K_CTRL_D,
 	K_CTRL_L,
blob - 9d083687063c4e4c052a31aa87a8db4a17ec48d2
blob + 8253851f7ef29861121167ec0be2a7fdb8bde858
--- src/fugu-tty/term_input.c
+++ src/fugu-tty/term_input.c
@@ -127,6 +127,75 @@ csi_key(int param, unsigned char final, struct ti_key 
 	return (0);
 }
 
+/*
+ * Parse an xterm SGR mouse frame: CSI < Cb ; Cx ; Cy M/m.  Basic mouse
+ * tracking reports wheel motion as button 64/65; modifier bits do not alter
+ * the low button bits.  Every complete mouse frame is consumed, including
+ * clicks, releases, and horizontal-wheel events that fugu does not use, so
+ * their coordinates can never leak into the editor as typed text.
+ */
+static enum ti_result
+step_sgr_mouse(struct term_input *ti, struct ti_key *out, int *again)
+{
+	size_t		 i = 3;
+	unsigned int	 button = 0;
+	int		 field;
+	unsigned char	 final;
+
+	for (field = 0; field < 3; field++) {
+		if (i >= ti->len)
+			return (TI_NONE);
+		if (ti->buf[i] < '0' || ti->buf[i] > '9')
+			goto malformed;
+		while (i < ti->len && ti->buf[i] >= '0' &&
+		    ti->buf[i] <= '9') {
+			if (field == 0) {
+				if (button <= 255)
+					button = button * 10 +
+					    (ti->buf[i] - '0');
+				if (button > 255)
+					button = 256;
+			}
+			i++;
+			if (i > 32)
+				goto malformed;
+		}
+		if (i >= ti->len)
+			return (TI_NONE);
+		if (field < 2) {
+			if (ti->buf[i] != ';')
+				goto malformed;
+			i++;
+		}
+	}
+
+	if (i >= ti->len)
+		return (TI_NONE);
+	final = ti->buf[i];
+	if (final != 'M' && final != 'm')
+		goto malformed;
+	consume(ti, i + 1);
+	*again = 1;
+	if (final != 'M' || (button & 64) == 0)
+		return (TI_NONE);
+	if ((button & 3) == 0) {
+		out->key = K_WHEEL_UP;
+		return (TI_KEY);
+	}
+	if ((button & 3) == 1) {
+		out->key = K_WHEEL_DOWN;
+		return (TI_KEY);
+	}
+	return (TI_NONE);
+
+malformed:
+	/* Make progress on a malformed bounded frame without swallowing the
+	 * ordinary byte that follows it. */
+	consume(ti, i + 1);
+	*again = 1;
+	return (TI_NONE);
+}
+
 /* Parse one CSI/SS3 sequence at buf[0]. */
 static enum ti_result
 step_escape(struct term_input *ti, struct ti_key *out, int *again)
@@ -150,6 +219,8 @@ step_escape(struct term_input *ti, struct ti_key *out,
 			*again = 1;
 			return (TI_NONE);
 		}
+		if (ti->len >= 3 && ti->buf[2] == '<')
+			return (step_sgr_mouse(ti, out, again));
 		{
 			size_t	i = 2;
 			int	param = 0, haveparam = 0;
blob - 33e812698b9756cd4f898ba089f68dfac855b13c
blob + b52badcb7e0f7370c675ffac5a63db6c48a127fc
--- src/fugu-tty/term_input.h
+++ src/fugu-tty/term_input.h
@@ -26,8 +26,9 @@
 /*
  * The terminal input decoder (behavior.md 2.1): a pure state machine
  * turning a raw byte stream from the terminal into editor key events --
- * UTF-8 assembly, CSI/SS3 escape sequences to key symbols, and xterm
- * bracketed paste (ESC[200~ ... ESC[201~) collected as one atomic event.
+ * UTF-8 assembly, CSI/SS3 escape sequences to key symbols, xterm SGR mouse
+ * wheel reports, and bracketed paste (ESC[200~ ... ESC[201~) collected as
+ * one atomic event.
  * It is fed bytes incrementally (input arrives in bursts) and drained one
  * event at a time, so nothing about input handling depends on wgetch and
  * the whole thing is unit-tested headless.
blob - 05f771be2db08fe8748ce411937e60884c6fc586
blob + a01c9a8bd283e2f1a2f45eca0eebf5effc9fb437
--- src/fugu-tty/ui.c
+++ src/fugu-tty/ui.c
@@ -67,6 +67,7 @@ struct dline {
 #define P_TOOL	5
 #define P_ERR	6
 #define P_DIM	7
+#define P_TEXT	8
 
 /* the conversation drawer (behavior.md 2.1): reference width, and the
  * narrowest terminal it will occupy without starving the transcript */
@@ -164,6 +165,24 @@ struct ui {
 
 static struct ui	*UI;		/* for the atexit terminal restore */
 
+/* Terminal modes owned by fugu-tty.  Disable in reverse order so every
+ * survivable teardown and external-editor handoff restores the terminal. */
+static const char terminal_modes_on[] =
+    "\033[?2004h\033[?1000h\033[?1006h";
+static const char terminal_modes_off[] =
+    "\033[?1006l\033[?1000l\033[?2004l";
+
+static void
+terminal_modes(int enable)
+{
+	if (enable)
+		(void)write(STDOUT_FILENO, terminal_modes_on,
+		    sizeof(terminal_modes_on) - 1);
+	else
+		(void)write(STDOUT_FILENO, terminal_modes_off,
+		    sizeof(terminal_modes_off) - 1);
+}
+
 /* ---- small helpers -------------------------------------------------- */
 
 static int
@@ -183,6 +202,8 @@ md_attr(const struct ui *u, int md)
 		a |= u->color ? COLOR_PAIR(P_CODE) : A_DIM;
 	if (md & MD_BULLET)
 		a |= A_BOLD;
+	if (u->color && !(md & (MD_HEADING | MD_CODE | MD_CODEBLOCK)))
+		a |= COLOR_PAIR(P_TEXT);
 	return (a);
 }
 
@@ -230,25 +251,27 @@ line_add(struct dline *l, int attr, const char *text, 
 /* Wrap plain UTF-8 text to width columns, one attr, into new lines. */
 static void
 render_plain(struct ui *u, const char *text, size_t len, int width, int attr,
-    const char *prefix)
+    const char *prefix, const char *continuation)
 {
 	struct dline	*l;
 	size_t		 i = 0;
 	int		 col;
 	size_t		 last_break, seg_start;
+	const char	*lead;
 
 	if (width < 1)
 		width = 1;
 	do {
 		l = line_new(u);
 		col = 0;
-		if (prefix != NULL && i == 0) {
+		lead = i == 0 ? prefix : continuation;
+		if (lead != NULL) {
 			int	pw = 0;
 			size_t	k;
 
-			for (k = 0; prefix[k] != '\0'; k++)
+			for (k = 0; lead[k] != '\0'; k++)
 				pw++;
-			line_add(l, attr, prefix, strlen(prefix), pw);
+			line_add(l, attr, lead, strlen(lead), pw);
 			col = pw;
 		}
 		seg_start = i;
@@ -334,7 +357,7 @@ render_diff(struct ui *u, const char *text, size_t len
 				attr = u->color ? COLOR_PAIR(P_TOOL) :
 				    A_BOLD | A_UNDERLINE;
 		}
-		render_plain(u, text + i, j - i, width, attr, NULL);
+		render_plain(u, text + i, j - i, width, attr, NULL, NULL);
 		i = (j < len) ? j + 1 : j;
 	}
 }
@@ -376,25 +399,26 @@ rebuild(struct ui *u)
 		switch (it->kind) {
 		case IT_USER:
 			render_plain(u, t, n, w,
-			    A_BOLD, "> ");
+			    A_BOLD, "> ", NULL);
 			break;
 		case IT_ASSIST:
 			render_assist(u, it, w);
 			break;
 		case IT_TOOL:
 			render_plain(u, t, n, w,
-			    u->color ? COLOR_PAIR(P_TOOL) : A_DIM, "tool: ");
+			    u->color ? COLOR_PAIR(P_TOOL) : A_DIM,
+			    "  tool: ", "  ");
 			break;
 		case IT_DIFF:
 			render_diff(u, t, n, w);
 			break;
 		case IT_NOTE:
 			render_plain(u, t, n, w,
-			    u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL);
+			    u->color ? COLOR_PAIR(P_DIM) : A_DIM, NULL, NULL);
 			break;
 		case IT_ERROR:
 			render_plain(u, t, n, w,
-			    u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ");
+			    u->color ? COLOR_PAIR(P_ERR) : A_BOLD, "! ", NULL);
 			break;
 		}
 	}
@@ -546,34 +570,55 @@ draw_input(struct ui *u, int row)
 }
 
 static void
-fmt_usage(const struct ui *u, char *out, size_t sz)
+fmt_token_count(int64_t tokens, char *out, size_t sz)
 {
-	const struct a_usage	*g = &u->status.usage;
-	int64_t			 pct = -1;
+	int64_t rounded;
 
-	if (u->window > 0 && g->input_tokens > 0)
-		pct = g->input_tokens * 100 / u->window;
-	if (g->cache_read > 0 || g->cache_write > 0)
-		snprintf(out, sz, "in %lld out %lld cache %lld/%lld",
-		    (long long)g->input_tokens, (long long)g->output_tokens,
-		    (long long)g->cache_read, (long long)g->cache_write);
-	else
-		snprintf(out, sz, "in %lld out %lld",
-		    (long long)g->input_tokens, (long long)g->output_tokens);
-	if (pct >= 0) {
-		char	pbuf[32];
-
-		snprintf(pbuf, sizeof(pbuf), "  %lld%%", (long long)pct);
-		strlcat(out, pbuf, sz);
+	if (tokens < 1000) {
+		snprintf(out, sz, "%lld", (long long)tokens);
+		return;
 	}
+	rounded = tokens / 1000 + (tokens % 1000 >= 500);
+	snprintf(out, sz, "%lldk", (long long)rounded);
 }
 
 static void
+fmt_context(const struct ui *u, char *out, size_t sz)
+{
+	char current[32], maximum[32];
+
+	fmt_token_count(u->status.context_tokens, current, sizeof(current));
+	if (u->window > 0) {
+		int64_t q, tail, pct;
+
+		fmt_token_count(u->window, maximum, sizeof(maximum));
+		q = u->status.context_tokens / u->window;
+		tail = (u->status.context_tokens % u->window) * 100 /
+		    u->window;
+		pct = q > (INT64_MAX - tail) / 100 ? INT64_MAX :
+		    q * 100 + tail;
+		snprintf(out, sz, "Context %s/%s %lld%%", current, maximum,
+		    (long long)pct);
+	} else
+		snprintf(out, sz, "Context %s/?", current);
+}
+
+static int
+context_warn(const struct ui *u)
+{
+	int64_t threshold;
+
+	if (!u->status.have_context || u->window <= 0)
+		return (0);
+	threshold = (u->window * 80 + 99) / 100;
+	return (u->status.context_tokens >= threshold);
+}
+
+static void
 draw_status(struct ui *u, int row)
 {
-	char	left[128], right[160], usage[96];
-	int64_t	pct = -1;
-	int	warn = 0;
+	char	left[128], right[160], context[96];
+	int	warn;
 
 	switch (u->status.state) {
 	case UI_BUSY:
@@ -604,15 +649,13 @@ draw_status(struct ui *u, int row)
 	}
 
 	right[0] = '\0';
-	if (u->status.have_usage) {
-		fmt_usage(u, usage, sizeof(usage));
-		strlcpy(right, usage, sizeof(right));
-		if (u->window > 0 && u->status.usage.input_tokens > 0)
-			pct = u->status.usage.input_tokens * 100 / u->window;
+	if (u->status.have_context) {
+		fmt_context(u, context, sizeof(context));
+		strlcpy(right, context, sizeof(right));
 	}
 	if (u->notice[0] != '\0')
 		strlcpy(right, u->notice, sizeof(right));
-	warn = (pct >= 80);
+	warn = context_warn(u);
 
 	move(row, 0);
 	attrset(warn ? (u->color ? COLOR_PAIR(P_ERR) : A_BOLD) : A_REVERSE);
@@ -993,7 +1036,7 @@ compose_begin(struct ui *u)
 		u->esc_pending = 0;
 	}
 	event_del(&u->ev_in);
-	(void)write(STDOUT_FILENO, "\033[?2004l", 8);	/* paste off */
+	terminal_modes(0);
 	endwin();
 	imsgev_send_chunks(&u->w->iev, FUGU_IMSG_UI_COMPOSE, 0, text, len);
 	imsgev_send(&u->w->iev, FUGU_IMSG_UI_COMPOSE_GO, 0,
@@ -1408,7 +1451,7 @@ compose_end(struct ui *u, const struct edit_result *r)
 		return;
 	}
 	u->composing = 0;
-	(void)write(STDOUT_FILENO, "\033[?2004h", 8);	/* paste back on */
+	terminal_modes(1);
 	refresh();			/* return from endwin to program mode */
 	clearok(stdscr, TRUE);
 
@@ -1537,9 +1580,7 @@ ui_render(struct worker *w, struct imsg *imsg)
 		if (p != NULL && len == sizeof(u->status)) {
 			memcpy(&u->status, p, sizeof(u->status));
 			u->busy = (u->status.state != UI_IDLE);
-			if (u->window > 0 && u->status.usage.input_tokens > 0 &&
-			    u->status.usage.input_tokens * 100 / u->window >= 80
-			    && !u->warned80) {
+			if (context_warn(u) && !u->warned80) {
 				add_item(u, IT_NOTE, "context is over 80% of "
 				    "the window -- consider /compact", 54);
 				u->warned80 = 1;
@@ -1657,7 +1698,7 @@ ui_stop(void)
 {
 	if (UI == NULL)
 		return;
-	(void)write(STDOUT_FILENO, "\033[?2004l", 8);	/* bracketed paste off */
+	terminal_modes(0);
 	endwin();
 	UI = NULL;
 }
@@ -1685,8 +1726,9 @@ ui_start(struct worker *w, int cols, int rows)
 		init_pair(P_TOOL, COLOR_CYAN, -1);
 		init_pair(P_ERR, COLOR_RED, -1);
 		init_pair(P_DIM, COLOR_BLUE, -1);
+		init_pair(P_TEXT, COLOR_WHITE, -1);
 	}
-	(void)write(STDOUT_FILENO, "\033[?2004h", 8);	/* bracketed paste on */
+	terminal_modes(1);
 
 	u = xcalloc(1, sizeof(*u));
 	u->w = w;