commit - 8d819406d7a0b6260ad8fd9433562a701de987e8
commit + babda3eaa7c8cc9a67a59ab6e6de39a31910713f
blob - /dev/null
blob + 04fa135dd892025bb9daaae87c151492d232f94f (mode 644)
--- /dev/null
+++ .scratch/context-compaction/issues/01-serialize-conversation-for-summarization.md
+# 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
+# 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
+# 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
+# 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
+# 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
+# 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
+# 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
+# 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
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
- `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`).
## 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
(`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
+# 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
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
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
- 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
`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
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
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
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
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);
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
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
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
+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
+/*
+ * 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
# 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"
# 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
* 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
"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\","
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
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
#include "sysprompt.h"
#include "skills.h"
#include "tooldefs.h"
+#include "tool_display.h"
#include "agentcfg.h"
#include "turn_mechanics.h"
#include "turn_txn.h"
/* 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];
}
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
/* 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
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
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
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,
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;
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
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;
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);
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");
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;
"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);
"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");
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");
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
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 .
.
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
.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
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
+/*
+ * 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
+/*
+ * 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
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:
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
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
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)
*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
/*
* 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
#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 */
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
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);
}
/* 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;
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;
}
}
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;
}
}
}
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:
}
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);
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,
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);
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;
{
if (UI == NULL)
return;
- (void)write(STDOUT_FILENO, "\033[?2004l", 8); /* bracketed paste off */
+ terminal_modes(0);
endwin();
UI = NULL;
}
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;