Commit Graph

672 Commits

Author SHA1 Message Date
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
4e4c7495bf merge: mount terminal frontend on final runtime
* commit 'e1eaf88b0d7c9b2f9922ffea6a2cbd47682e746d':
  docs(terminal): cite the discriminating tail payload
  docs(terminal): say why tail_full cannot fire, not just that nothing calls it
  test(terminal): require the runtime to pump deferred work
  docs(terminal): say that the tail-depth signals have no consumer yet
  docs(terminal): repoint the links the slice_bytes deletion broke
  test(terminal): reject stop before child reap
  refactor(terminal): delete the slice-sizing function nothing calls
  test(terminal): make the decrease and RIS arms assert what they claim
  fix(terminal): retain the scrollback debt a shrink does not immediately repay
  fix(terminal): repair three defects the gate found in the work-bounded seam
  fix(terminal): raw-drain while child exits
  feat(terminal): bound the lock hold by weighted work, not by bytes
  test(terminal): close the review gaps in the cluster and snapshot contracts
  feat(terminal): give an attaching subscriber the screen as it stands
  feat(terminal): give the renderer each cluster's true column

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
2026-08-02 02:07:12 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
e1eaf88b0d docs(terminal): cite the discriminating tail payload
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 02:01:55 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
df19569e7c Merge final terminal backpressure documentation
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

* commit '232f56b67219613ddb1fd6628a73ae31bd1156a5':
  docs(terminal): say why tail_full cannot fire, not just that nothing calls it

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:59:18 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
232f56b672 docs(terminal): say why tail_full cannot fire, not just that nothing calls it
The previous wording said the signal has no consumer. That is the weaker half
of the fact and the half that ages badly: a reader who deletes the pump loop
learns the rest by wedging a terminal.

Both measurements are now in the comment. With the loop, 1 MiB of pure RIS
through 16 KiB reads leaves a tail high-water of exactly 0 -- the tail is
empty between iterations, so the predicate is unreachable by construction.
Without it, the same stream reaches TAIL_CAP at read 257. Reproduced here
before quoting: 0 and 257 both hold on this tree.

Wording is Sami's, who measured it first and made the argument that the
signal and the loop are one fact from two sides. Delete the loop and this
predicate stops being unreachable in the same instant it starts being needed.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:56:59 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
73d00c6013 Merge terminal scheduler documentation
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

* commit '9ca661ee8e684d720724673b69c8f8a110fb72b2':
  docs(terminal): say that the tail-depth signals have no consumer yet
  docs(terminal): repoint the links the slice_bytes deletion broke

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

# Conflicts:
#	desktop/src-tauri/crates/buzz-terminal/src/fences.rs
#	desktop/src-tauri/crates/buzz-terminal/src/lib.rs
#	desktop/src-tauri/crates/buzz-terminal/src/reader.rs
2026-08-02 01:55:55 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
5f416f7d25 test(terminal): require the runtime to pump deferred work
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:53:12 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
9ca661ee8e docs(terminal): say that the tail-depth signals have no consumer yet
`tail_full` and `tail_drained` are the queue bound, and nothing outside the
tests calls them: the runtime reader pumps `drain` to completion after every
read, so the tail cannot reach the cap and nobody needs to ask. That is fine
and it is the design -- the signal was built for the reader that stops
pumping -- but an exported, documented predicate with an empty call graph
reads as wired to anyone who doesn't go looking. Stated in the doc comment
instead, because an unused signal that looks connected is worse than one that
says it isn't.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:52:08 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
651ef710b4 docs(terminal): repoint the links the slice_bytes deletion broke
Two intra-doc links still resolved to the deleted function, so `cargo doc`
emitted unresolved-link warnings. Neither `cargo test` nor clippy sees those,
which is why they survived a green gate -- the deletion was verified by the
checks that run in CI and not by the one that reads the docs.

The single remaining mention is deliberate: the paragraph in `fences.rs`
explaining why the old function existed and why it is gone.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:48:49 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
3d4fc49df9 Merge terminal scheduler fixture correction
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

* commit 'd67b99a3dd611f0c239d9bce13552417acaba622':
  refactor(terminal): delete the slice-sizing function nothing calls

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:47:32 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
fe7d291f48 test(terminal): reject stop before child reap
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:43:11 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
d67b99a3dd refactor(terminal): delete the slice-sizing function nothing calls
`slice_bytes(columns, lines, scrollback)` stopped being the scheduler's
function when slices became remaining-aware, and the fixtures went on
asserting against it. The two disagreed exactly where the old floor bound --
reporting 4 bytes where `drain` used 1 -- so the preconditions guarding the
decrease arm were describing behaviour that no longer existed. Not wrong at
today's geometries, and no test would have noticed when it became wrong.

It had zero callers outside the tests it misled. Deleted, with every
assertion moved to `slice_bytes_remaining`, which is what the engine calls.
One function, one answer.

`MIN_SLICE` goes with it: the floor is 1 and lives in the function, because
on a grid whose worst atom exceeds the whole budget no wider slice can
promise to stop after the callback that crosses. A named constant that only
appeared in a doc comment is a second authority waiting to disagree with the
first.

69 tests, debug and release. Test-only in effect; the deleted item was
unreachable from the engine.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:42:44 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
7a75b4e170 Merge terminal fixture repair
* commit '84b274d3c5da05257df95953b3f5115ead5420dc':
  test(terminal): make the decrease and RIS arms assert what they claim

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:38:21 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
84b274d3c5 test(terminal): make the decrease and RIS arms assert what they claim
Test-only. Five gaps, four of them found independently by two reviewers,
which is the part worth noting: they are the kind that pass.

The decrease arm captured `first_pending` and never asserted it, compared
`first_units` to the literal `12` rather than the control's own field, and ran
its geometry-persistence check on a *different* terminal -- one that had only
ever grown. That last one is the sharp one. It would pass an implementation
that retained the debt on shrink and dropped it on the next geometry resize,
which is a real shape: `Feeder::resize` sees every resize, and only the
scrollback branch is conditional. Verified by writing that mutant: the arm now
fails `left: 80000, right: 4040000`, and it did not before.

Two things had to be true for it to bite, and it was inert without either. It
runs on the terminal that actually went shallow -> deep -> shallow, and the
resize carries the *shallow* depth -- handing the debt's own value back in
makes `max(debt, new)` and a plain assignment agree, so the arm cannot tell
them apart. I found that second one by writing the mutant, watching it
survive, and looking again.

The RIS table collected all six arms before asserting but then asserted them
in a loop, so a failure still printed one. Collecting stops an arm being
skipped; comparing the vectors is what stops the failure being truncated. With
the history term deleted the failure now shows all six rows and which ones
moved, rather than the first mismatch.

Every comparison in the decrease arm is now against the control's own field.
A constant or geometry change has to move both sides together, or the fixture
quietly starts asserting the arithmetic of the day it was written.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:37:44 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
03499c956e Merge Dawn's terminal scheduler repair
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:32:40 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
e1500806d3 fix(terminal): retain the scrollback debt a shrink does not immediately repay
The previous commit lowered the feeder's scrollback on a decrease, which reads
as correct and is not: upstream frees history lazily. `Storage::shrink_lines`
truncates only once the buffer exceeds the new length by `MAX_CACHE_SIZE`, so
immediately after a shrink the grid still owns rows a reset has to walk.
Pricing at the new depth charges for a grid that does not exist yet.

So the feeder tracks a high-water mark instead of the current depth. It never
falls, needs no clearing transition, and cannot go stale in the unsafe
direction. The cost is real and stated: a session that shrinks its scrollback
keeps paying the deep price for the rest of its life. The alternative is a
bound that is wrong immediately after every shrink.

The fixture that permitted this is the more important half. It asserted
`shrunk_work >= fresh_shallow`, which a feeder that dropped the debt satisfies
*by equality* -- "never had a debt" and "dropped the debt" are the same state,
so any predicate anchored to a fresh control blesses both. Strictness on the
pricing field is what separates them, and the pricing field is the only one
that can: the scheduling observables separate only when the two depths land on
different slice widths, and both are usually floored. The arm now asserts
`completed_work > fresh_shallow` strictly, with the scheduling fields carrying
their per-field directions -- `first_units` inverts, because a narrower slice
retires fewer atoms per un-preemptable drain, which is the fence working.

Also here, both from the same gate:

* Monotonicity is swept per axis. A truncating `columns * lines` is a
  non-monotone middle on the columns axis alone, and a scrollback-only sweep
  cannot see it.
* Seventeen more rows in the pricing table -- `scroll_down`, `insert_lines`,
  `put_tab`, forward tabs, both `clear_line` modes, both `clear_screen` modes,
  and the parameter extremes for each. The four that were missing are the four
  whose weights share a shape with ones already covered, which is exactly the
  argument for covering them separately rather than by analogy.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:30:26 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
1a2c86ebed fix(terminal): repair three defects the gate found in the work-bounded seam
All three were pre-registered predictions that the fixtures at a957d25b0 did
not cover, and each was independently reproduced before being fixed.

**Geometry ownership.** `Terminal::resize` took a whole `Size` and forwarded
two of its three fields to the feeder, so a scrollback change updated the
`Term` while the feeder kept charging its construction depth. That is most of
RIS's price -- 3,840 charged against 803,840 honest at 80x24 -- and because
`slice_bytes` is derived from the densest atom, it also left the *scheduler*
sized for the wrong grid: a stale 130-byte slice admits 65 RIS atoms where the
honest 4-byte slice admits two. The split was permanent; later resizes tracked
their own fields and never re-derived the third. `Feeder::resize` now takes
the whole `Size`, so there is one ownership boundary and no way to update two
of three.

A bound derived at construction from a value that can change afterwards is a
bound with an expiry date.

**Post-atom overrun.** Slices were sized against the whole budget, so a slice
could hold an oversized atom *and* the callbacks behind it: `ESC c` followed
by `Xmore` ran three callbacks where the law permits one. Width is now a
function of what *remains* of the budget, which is a single byte once an atom
worth more than the budget is in play. Two refinements the fixtures forced:
plain runs are metered by the plain-byte cost and stop at the next `ESC`
rather than being priced as though every byte might be RIS -- pricing them
that way is correct and costs 181 MB/s -> 69 MB/s for a bound on something
that cannot happen -- and the escape scan is capped at `MAX_SLICE`, because
searching the whole tail per slice is quadratic and measured as a 7x
regression, a bound costing more than the thing it bounds. A `mid_escape` flag
keeps a sequence split across slices on escape metering; without it the byte
after a lone `ESC` looks like text and the atom rides into a wide slice.

**Saturating arithmetic.** `columns * lines` was computed in `usize` before
the cast, and `Size` is unclamped with no caller bounding it. Debug panicked
inside the accounting path -- reachable from `drain`, which is the pump a
paused reader must keep calling -- and release wrapped. The wrap is the worse
half: it does not degrade the fence, it inverts it, because `slice_bytes`
divides the budget by the atom cost, so an undercharged atom yields a *wider*
slice exactly when the atom is most expensive. Convert first, saturate
throughout.

The fixtures are where most of the work went, because three of them had to be
rewritten after the obvious version passed on the broken tree:

* Resize is asserted by equality with a terminal *constructed* at the target
  depth, across charge and acquisition count -- stronger than any threshold
  and immune to the constants moving, since both arms move together. A sanity
  arm proves the comparison is deterministic first.
* Shrinking is asserted *directionally* after establishing a debt
  (shallow -> deep -> shallow). Equality would forbid conservative retention,
  and resizing straight down leaves a feeder indistinguishable from one built
  shallow -- which is also the signature of a feeder that never updated.
* `completed_units` is asserted but is explicitly *not* the discriminator: it
  reads the same in both arms, because the same callbacks run either way.
* Saturation asserts the direction -- widest atom, narrowest slice -- plus
  monotonicity across nine decades. "Does not panic" is satisfied by the
  wrapping version, which is the version that inverts the fence.
* Preconditions are asserted rather than assumed: the geometry must let the
  scheduling fields separate at all, and the payload must be RIS, the only
  escape reaching the only weight with a scrollback term.

24 mutants dead in debug and release, including one per repair.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:26:26 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
04e9d0dc7e fix(terminal): raw-drain while child exits
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:00:39 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
1a06fffddc Merge commit 'a957d25b02881bad31bb0d6ab6c9571545ba9bc8' into mari/tui-integration
* commit 'a957d25b02881bad31bb0d6ab6c9571545ba9bc8':
  feat(terminal): bound the lock hold by weighted work, not by bytes
  test(terminal): close the review gaps in the cluster and snapshot contracts
  feat(terminal): give an attaching subscriber the screen as it stands
  feat(terminal): give the renderer each cluster's true column

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

# Conflicts:
#	desktop/src-tauri/crates/buzz-terminal/src/damage.rs
#	desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs
#	desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs
2026-08-02 00:53:06 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
a957d25b02 feat(terminal): bound the lock hold by weighted work, not by bytes
Both existing fences meter bytes. That is right for memory and wrong for
time: `ESC[m` and `ESC#8` are four bytes each, and the second rewrites every
cell. A DECALN flood therefore reaches p95 65535us against a 4000us budget
with **zero** F1 aborts -- the fence never fires, because nothing is
buffered. Unfenced, one acquisition was observed at 22.1s.

So add a third quantity. `Counting` wraps the handler and charges each
callback what it touches: an O(cells) callback costs `columns * lines`, an
O(1) callback costs 1. `Feeder` accepts bytes into a pending tail and parses
only what one work budget affords, leaving the rest for the next call. That
bounds the hold. It does not bound the queue, so the tail has a cap and a
loud breach counter, and the reader is told to stop reading via a depth query
rather than a pause flag the fence owns -- a flag is a state a reader can fail
to clear, which is how a paused reader strands a child mid-teardown.

The weights are read from `alacritty_terminal-0.26.0/src/term/mod.rs` and then
checked against measurement, never fitted. Where source and measurement
disagree the source wins and the slack is recorded in the table. Three
findings from that reading are worth naming:

* `delete_chars`/`insert_blank` cost `columns` for *every* N and are worst at
  N=1 -- the swap loop runs `columns - end` times, so cost falls as N rises.
  A sweep that varies only N reports them parameter-dependent *decreasing*,
  and the honest reading of that invites charging by N, which is backwards.
* `move_backward_tabs` (CBT) assigns `col` inside the `if self.tabs[i]` test,
  so with tabstops cleared the cursor never moves, its `col == 0` exit is
  unreachable, and all N iterations rescan the row: `ESC[3g ESC[65535Z` is
  eight bytes for 82ms at 1600 columns. Its twin `move_forward_tabs` assigns
  outside the test and is fine. The wrapper stops it at the first fixed point
  -- permanent, because the scan depends only on the cursor -- which makes it
  O(columns) and, verified exhaustively over every tabstop subset of a
  12-column grid, lands on the same column as the naive loop.
* `reset_state` (RIS) resets both grids and walks the primary's scrollback, so
  two bytes can be worth more than the whole budget. Priced on *configured*
  depth, not `history_size()`, which observes only the active grid: a filled
  primary followed by `ESC[?1049h` reads as empty while the work is still paid.

Slice size is derived from the densest atom the grid admits rather than
chosen, so the bound holds on the first byte of a cold feeder. An earlier
version sized slices from observed density and was strictly worse where it
matters -- a fresh feeder has seen nothing, so its first slice is wide.

`Terminal::feed` now returns whether a tail remains and the caller pumps
`drain` between lock acquisitions. `feed_fully` is the whole-buffer form,
deliberately a separate name because it reinstates the unbounded hold.

Every fixture asserts exact counts, never `> 0` and never `<=` alone. Both
directions are load-bearing: with the CBT break deleted the callback loops
65535 times while charging `work == 1`, so an upper bound passes over it, and
the mutant lands on the same cursor column as the fix, so a semantic assertion
passes too. 20 mutants dead in debug and release.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 00:46:05 -04:00
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
7d56109ecb chore(desktop): keep app entrypoint within size budget
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
2026-08-01 22:24:18 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
4bd3b03a3c fix(terminal): recover attach publication race
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-01 22:11:47 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
e887bdb2c4 test(terminal): close the review gaps in the cluster and snapshot contracts
Six review findings, all coverage on code that was already correct, plus one
enforcement change.

Clusters. The join guard has two halves and only one was exercised: every
fixture built a plain cluster after a marked one, none the reverse. Drop
`joinable` and a release build silently emits `Span { text: "xye\u{301}",
cluster_count: 3 }` -- four chars counted as three, so the consumer misplaces
everything after it. Also fixture the `u16::MAX` join refusal, which is live
rather than defensive: `Size.columns` is an unclamped `usize` with no caller
bounding it, so a 70000-column row reaches it and must split rather than wrap.

Promote the encoder's `debug_assert` to `assert!`. This is a wire invariant,
and a check that vanishes in release vanishes exactly where the corruption
ships: with the defect present, the promoted build fails at the producer
rather than leaving an undecodable span for the renderer to misdraw. Measured
at +5.9us on a 65us encode for 80x24 -- 0.04% of a 60Hz frame.

Snapshot. The four fixtures asserted the damage plane and nothing else, so a
capture that dropped the screen's last row, lied about the cursor, or stamped
a stale grid identity passed all of them. `visible_text` trims and filters
empty lines, which is what let the missing row disappear -- a helper written
for legibility concealing the defect it should have exposed. Assert the row
set rather than the text, the cursor against a real position and DECTCEM, the
whole `Viewport` rather than `columns` alone, and that a full-grid copy is
billed to the renderer's meter and not the reader's.

Findings from Sami's P2a and P2b reviews; enforcement call from Eva; encode
cost measured by Sami.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 22:02:32 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
ba4ff04fea feat(desktop): wire terminal sessions to Tauri
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-01 21:52:32 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
1a213fe709 feat(terminal): give an attaching subscriber the screen as it stands
A subscriber that arrives mid-stream cannot start from `render()`. Damage
describes what changed since someone last looked, so a newcomer is handed
whatever happens next -- on a quiet terminal, the cursor's line alone --
painted onto a blank screen. Upstream's `mark_fully_damaged` is private, so
an embedder cannot ask for a full frame that way either.

`snapshot()` copies the whole visible viewport instead, marks the frame
full so a reused encoder's dedup hashes realign, and stamps the geometry it
was captured under.

The delicate part is what it does not do. Damage is a single shared cursor
across every subscriber, so a snapshot that consumed it would steal the
incumbent renderer's pending rows: the newcomer's full frame would look
perfect while the established renderer silently froze. `capture_all`
therefore never calls `damage()` or `reset_damage()`, and their absence is
the mechanism.

Proving that needed more care than the interleaving alone. Because
`Term::damage()` marks the cursor line on every call, an incumbent owed
only the row it sits on gets that row back even when its damage was
stolen -- so the first version of the fixture passed against a deliberately
naive implementation. The committed fixture rewrites row 0 and parks the
cursor on row 3, putting the owed row where the cursor cannot return it for
free; it fails when snapshot consumes damage, and again when snapshot
copies damaged rows only or omits the full flag.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:44:36 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
3a5c7d576e feat(terminal): give an attaching subscriber the screen as it stands
A subscriber that arrives mid-stream cannot start from `render()`. Damage
describes what changed since someone last looked, so a newcomer is handed
whatever happens next -- on a quiet terminal, the cursor's line alone --
painted onto a blank screen. Upstream's `mark_fully_damaged` is private, so
an embedder cannot ask for a full frame that way either.

`snapshot()` copies the whole visible viewport instead, marks the frame
full so a reused encoder's dedup hashes realign, and stamps the geometry it
was captured under.

The delicate part is what it does not do. Damage is a single shared cursor
across every subscriber, so a snapshot that consumed it would steal the
incumbent renderer's pending rows: the newcomer's full frame would look
perfect while the established renderer silently froze. `capture_all`
therefore never calls `damage()` or `reset_damage()`, and their absence is
the mechanism.

Proving that needed more care than the interleaving alone. Because
`Term::damage()` marks the cursor line on every call, an incumbent owed
only the row it sits on gets that row back even when its damage was
stolen -- so the first version of the fixture passed against a deliberately
naive implementation. The committed fixture rewrites row 0 and parks the
cursor on row 3, putting the owed row where the cursor cannot return it for
free; it fails when snapshot consumes damage, and again when snapshot
copies damaged rows only or omits the full flag.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:43:48 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
f3e7eb16c9 feat(terminal): give the renderer each cluster's true column
The span encoding shipped a concatenated string and a start column, which
left the consumer to recover cell boundaries from the text. It cannot: a
regional-indicator flag is two one-column cells and must split per
codepoint, while a keycap is one cell holding three codepoints and must
split per grapheme. Those rules are opposite, and the distinction lives in
the grid, not in the string. Any split rule over the concatenated text is
wrong for one of the two.

So the span carries what the grid knows. `width` is the columns each
cluster advances, uniform across a run by construction; `cluster_count`
says how many clusters the text holds. Decoding is then arithmetic with no
Unicode table anywhere: a count of 1 means the whole text is one cluster,
otherwise cluster i is the i-th char at `column + i * width`.

Geometry flags are also masked out of the style key. `WIDE_CHAR` and
`WRAPLINE` are grid bookkeeping, not appearance, and while they sat in the
key they broke runs as a side effect -- hiding the explicit width check
behind a coincidence, and splitting the last column off every wrapped row
into a span of its own. Row dedup still hashes the full flags, so nothing
it needs to see is lost.

Eight fixtures cover the contract, including the flag/keycap pair whose
opposing rules make the case. Six mutations were run against them and each
fails independently: forcing width to 1, dropping the width comparison,
never breaking on zerowidth marks, removing WRAPLINE from the mask,
removing the mask entirely, and never incrementing cluster_count.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:34:04 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
e78491767b feat(terminal): give the renderer each cluster's true column
The span encoding shipped a concatenated string and a start column, which
left the consumer to recover cell boundaries from the text. It cannot: a
regional-indicator flag is two one-column cells and must split per
codepoint, while a keycap is one cell holding three codepoints and must
split per grapheme. Those rules are opposite, and the distinction lives in
the grid, not in the string. Any split rule over the concatenated text is
wrong for one of the two.

So the span carries what the grid knows. `width` is the columns each
cluster advances, uniform across a run by construction; `cluster_count`
says how many clusters the text holds. Decoding is then arithmetic with no
Unicode table anywhere: a count of 1 means the whole text is one cluster,
otherwise cluster i is the i-th char at `column + i * width`.

Geometry flags are also masked out of the style key. `WIDE_CHAR` and
`WRAPLINE` are grid bookkeeping, not appearance, and while they sat in the
key they broke runs as a side effect -- hiding the explicit width check
behind a coincidence, and splitting the last column off every wrapped row
into a span of its own. Row dedup still hashes the full flags, so nothing
it needs to see is lost.

Eight fixtures cover the contract, including the flag/keycap pair whose
opposing rules make the case. Six mutations were run against them and each
fails independently: forcing width to 1, dropping the width comparison,
never breaking on zerowidth marks, removing WRAPLINE from the mask,
removing the mask entirely, and never incrementing cluster_count.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:33:34 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
c17f2d2c3d terminal: process-group lifecycle with drain-ordered teardown
Closing a tab must end the work the tab was doing. The child is a session
leader (portable-pty calls setsid), so signalling its pid reaches the shell
and leaves everything the shell started running, reparented to init and
holding the pty slave. Signal the process group instead: SIGTERM, a bounded
grace, then SIGKILL to the group whether or not the leader went quietly --
a polite leader does not imply an empty group.

Three things this found that reading could not.

waitpid(WNOHANG | WNOWAIT) returns EINVAL on Darwin. POSIX defines WNOWAIT
only for waitid; Linux tolerates it, macOS does not. The failure was silent
because `seen == pid` is false for -1 exactly as it is false for "still
running" -- error and negative result collapsed into one branch. Every
shutdown escalated to SIGKILL and the polite arm was dead code on the
platform we develop on. Only an assertion on *which arm fired* could see it.

A grandchild test that does not defeat the tty hangup proves nothing.
Killing a session leader makes the kernel SIGHUP the foreground group, so
the grandchild dies either way and a pid-only mutation survives. The fixture
must ignore SIGHUP and busy-loop; then pid-only leaks and kill(-pgid) does
not.

A child blocked writing to an undrained master does not die promptly even
on SIGKILL -- measured 606ms to reap, versus microseconds when drained.
So the reader outlives termination and reap. shutdown_draining takes the
reader by value, which makes the forbidden order a compile error rather
than a comment: a joined reader has been consumed and cannot be passed.

Mutation matrix, sources restored byte-identically after each:

  L1 delete SIGKILL escalation  -> FAIL (orphan + grace arms)
  L2 kill(pid) not kill(-pgid)  -> FAIL (orphan arm)
  L3 delete pre-reap try_wait   -> FAIL (Killed, want AlreadyExited)
  L4 join reader before SIGTERM -> FAIL (10.01s, grace is 250ms)

Fixtures built to ignore signals carry their own SIGKILL deadline. Without
it a legitimate failure leaves a core spinning at PPID 1, and L1/L4 were
detected by hanging -- a test that finds a bug by never finishing cannot be
told from a broken one. The deadline sits far outside the observation
window so it can never rescue a failing implementation.

2088 desktop + 3 mixer + 26 buzz-terminal + 5 fences + 4 resize green,
clippy --workspace --all-targets -D warnings clean, fmt clean.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:20:42 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
f05ef3170f feat(desktop): enforce terminal frame publication credit
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:06:20 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
c3c705583b feat(desktop): fence the terminal child's environment, PATH, and shell
A PTY child inherits its parent's environment by default, and Buzz's own
process holds the user's nsec. `CommandBuilder::new()` pre-seeds its env map
from `std::env::vars_os()` (`cmdbuilder.rs:218` -> `:74`), so the naive spawn
hands a live shell 70 variables including `BUZZ_PRIVATE_KEY`: press the toggle,
type `env`, read the signing key off the screen. Verified against a real PTY,
not inferred.

Three fences, each with the failure it exists to prevent:

* Environment: `env_clear()` first, then rebuild from an allowlist. Order is
  load-bearing and the reverse fails silently -- clearing after populating
  yields an empty environment and no error -- so the gate asserts both
  directions. Allowlist rather than denylist because a denylist is only as
  current as the last person who remembered to extend it.

* PATH: constructed, never inherited, never filtered. Buzz runs under Hermit
  activation, so an inherited PATH makes the user's `cargo` our pinned build
  toolchain -- on Linux, where no `path_helper` reorders it, at the front. The
  test seeds a uniquely-named executable into the parent PATH and proves the
  child cannot resolve it, rather than comparing PATH strings.

* Shell: `$SHELL` -> passwd -> `/bin/sh`, each candidate validated as an
  executable regular file. `access(X_OK)` alone accepts a directory, and a
  `$SHELL` of `/tmp` produces a child that aborts with a Rust runtime panic
  while `get_shell()`, `spawn_command()`, and every layer above report success.
  Raw mode bits alone accept a file the caller cannot execute. The conjunction
  is the check. The resolved shell is injected as `SHELL`, not inherited: those
  differ in exactly the cases the fallback chain exists for.

GUI context crosses a trust boundary. A channel name is attacker-controlled and
lands in a variable shells interpolate into prompts, so `BUZZ_CHANNEL` is
validated against a conservative character class and, on rejection, replaced by
the channel UUID rather than stripped -- a stripped `$(evil)` becomes `evil`,
which looks like a real channel.

Nineteen fixtures, each shown failing under the mutation it exists to catch:
deleting `env_clear`, reordering it to the end, inheriting PATH, denylisting
instead of allowlisting, dropping either half of the executability predicate,
inheriting `SHELL`, sniffing the shell's name for login flags, removing the
passwd candidate, weakening the shared validator, skipping channel-name
validation, stripping instead of substituting, dropping the length cap,
letting the display name reach `BUZZ_CHANNEL_ID`, emitting an empty
`BUZZ_THREAD_ID`, accepting `=` in a key, and adding a credential to the
desktop crate's reserved list without covering it here.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:43:26 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
54cd502009 docs(desktop): record why G3's max bound cannot be widened
The p95 comment claimed 31x of headroom "lets this run on a loaded CI
runner," and read as if it justified the whole gate. It justifies the
quantile only. A p95 discards its worst samples by construction and so
degrades gracefully under load; a max over 200 samples is one
observation that any single scheduler preemption exceeds. No budget
makes it robust, because that tail belongs to the scheduler.

Measured on one 16-core host at the same commit: pass at load ~6, fail
at ~31 (p95 65535 us, max 164889 us). Sami measured a run where p95 sat
4x inside budget while max alone blew at 38150 us -- a p95-only gate
would have called a 38 ms stall green.

That asymmetry is what makes the ignore-plus-native-release placement
load-bearing rather than tidy, and it is the reason the repair for a
flake here is to fix the host, never to raise the number: a hitch is a
max-event, so widening the max is the one change that removes the only
assert catching the user-visible failure.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:41:43 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
25fcb5b26d Merge branch 'dawn/tui-engine-core' of ../buzz-tui-dawn into tui-substrate-perci
* 'dawn/tui-engine-core' of ../buzz-tui-dawn:
  feat(desktop): make a frame's viewport comparable in one value

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:15:26 -04:00
tlongwell-block
105aadb22c feat(desktop): make a frame's viewport comparable in one value
The integration lane's core operation is "does this frame describe the grid I
am currently showing?" -- a frame captured before a resize can arrive after it,
because frame delivery and the resize call travel separate paths. Answering
that meant comparing three loose fields, and a consumer that checks two of the
three is wrong on exactly the resize that changed the one it skipped.

Group them into `Viewport { generation, columns, screen_lines }` so the
question is one equality, and return it from `resize` rather than making the
caller ask afterwards: across a transport a follow-up query races the next
resize, so its answer can describe a grid that has already been replaced. A
same-size resize stays inert and reports the unchanged generation.

Two mutants, both caught: returning the requested viewport instead of the
applied one, and stamping a frame at encode time instead of capture time.

The stamping fixture was tautological on its first draft -- it compared a
frame's viewport against a copy taken from that same frame, which the language
already guarantees. It now asserts capture-time values explicitly, and the
comment records why the obvious form proves nothing.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:12:36 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
59648f0706 test(desktop): harden terminal engine gates
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:10:30 -04:00
tlongwell-block
a094adce9a feat(desktop): add the resize seam to buzz-terminal
The engine had no resize entry point, so the integration lane was about to
build a coalescing protocol against a `Frame` that could not say which
viewport it described. A consumer could only infer that from message
ordering, which is exactly the thing a resize in flight breaks.

`Terminal::resize` takes one coalesced target size, never a stream: resize is
superlinear in scrollback (2.5-4.7 ms for a single column change at 10k
history) and runs while holding the terminal. Frames now carry `generation`,
`columns` and `screen_lines`, so a full frame is self-describing and a grid
discontinuity is detectable from the frame rather than assumed from ordering.

Three fixtures, each shown failing under its mutation. One of them only
exists because the first version of this test suite was vacuous:

Deleting the encoder's dedup-cache clear left all the resize tests green.
The width-changing fixture cannot catch it -- changing the width changes
every cell, so the hashes differ and the rows are republished for the wrong
reason. A height-only resize is the discriminator: the width is unchanged, so
reflowed rows hash exactly as before and a stale cache suppresses them right
after the consumer was told to discard what it had. Growing 10 -> 20 lines
moves row 0 to row 1, and under the mutation the frame carries 13 of 20 rows
-- seven rows blank on screen. With that arm added the mutant dies.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:07:49 -04:00
tlongwell-block
7e7731b89b feat(desktop): add buzz-terminal engine crate
Scaffolds the terminal emulator behind the Buzz substrate: grid state, the
parser and its two hardening fences, the closed listener set, and the damage
encoding the renderer will consume. No PTY, no child process, no window --
those belong to the embedder, so this crate stays testable against byte
fixtures on every platform.

Three things ship together because each is load-bearing for the others:

* The crate itself.

* The explicit `members` key in the workspace manifest. With a bare
  `[workspace]`, `cargo test/check --workspace` expands to a set that
  excludes this crate, and its gates pass green-and-empty over a real
  defect. Verified as a four-arm matrix on this repo.

* A file-size ratchet rule for `src-tauri/crates`. The ratchet's only Rust
  root was `src-tauri/src`, so the crate would be born outside the repo's
  one size discipline -- silently, since the check exits 0. Verified: a
  1500-line file under `crates/` passes without the rule and fails with it,
  while the real crate passes.

Two fences, both enforced on byte counts rather than a clock, because a
clock makes the bound depend on machine speed:

* F1 aborts a synchronized update once it buffers 64 KiB.
* F2 rebuilds the parser once 256 KiB of parser-visible bytes are charged.

F1 turned out to be dual-purpose. Measured under a flood, the reader's lock
hold is p50 1 us while the renderer's *wait* for the same lock is p50
4245 us -- so the plan's original "reader hold under one frame" requirement
passes trivially while the window is visibly stuck. The gate is now the
renderer's acquire latency, and F1 is what holds it down: p95 127 us with
the fence, 16383 us without.

That gate also needs a fair lock. `std::sync::Mutex` lets the read loop
barge, and the renderer completed 12 frames to the reader's 39893
acquisitions; `FairMutex` under the same flood gives 76 and 63 us.

Six fixtures, each shown failing under the mutation it exists to catch:
deleting F1, deleting F2, omitting flush accounting, charging raw input,
and swapping the fair lock for a barging one.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 19:56:44 -04:00
kiranmagic7
3ade48d503 fix(desktop): point Oh My Pi preset at omp.sh (#3516)
## Summary

Points the Oh My Pi preset at the `omp.sh` installation page instead of
the GitHub repository.

The project serves its current installer from `omp.sh/install.sh`.

### Related issue

Extracted from the maintainer request in #3111. I found no matching open
pull request in a final duplicate check.

### Testing

`https://omp.sh/` returned HTTP 200 with the installation page.

`https://omp.sh/install.sh` resolved to the current installer and
returned HTTP 200.

`cargo test --manifest-path desktop/src-tauri/Cargo.toml preset_entry --
--nocapture` passed 5 tests.

`just ci` passed.

This changes metadata only, so screenshots do not apply.

Signed-off-by: Shreyash Vengurlekar <262980978+kiranmagic7@users.noreply.github.com>
Co-authored-by: Shreyash Vengurlekar <262980978+kiranmagic7@users.noreply.github.com>
2026-07-31 22:30:10 -04:00
Mic Neale
fa1a5b1a79 fix(mesh): stop restarting a busy or loading shared-compute node (#3909)
## Problem

Sharing compute with a large model (e.g. `gemma-4-26B`) put the desktop
app into a **restart loop**: toggle Share → app appears to "download" /
stall → the whole app restarts → repeat. Small models (E4B) were
unaffected, which made it look model-specific and flaky.

It is not model-specific and not flaky. It is a **false-positive
liveness check**.

## Root cause (proven by black-box measurement)

A `serve` node's OpenAI ingress (`:9337`) serializes **all** HTTP —
including the `/v1/models` liveness probe — behind the current in-flight
inference. It is *also* HTTP-unresponsive during model load and
package-layer download. In every one of those phases the node is alive
and progressing, but it cannot answer an HTTP probe.

Measured on a standalone `gemma-4-26B` node (randomized ~30k-token
prompt, cache-miss):

| during one ~30s inference | result |
|---|---|
| concurrent `GET /v1/models` | **27.0s**, then 200 |
| concurrent small `/chat/completions` | **28.8s**, then 200 |
| `tcp_connect(:9337)` throughout | **~0ms** |

Both HTTP calls simply queued behind the turn; TCP kept accepting
instantly. A probe with any timeout shorter than the turn reads the node
as dead.

Buzz then acted on that false "dead" reading in two places, **both
restart paths added in #2823**:

1. **Ingress watchdog** — after 2 consecutive `/v1/models` timeouts,
evicts the node; for a serve node eviction means
`app.request_restart()`. Two dead probes landing inside a prefill window
→ restart loop.
2. **Start / restore paths** — on a `wait_for_mesh_inference` timeout,
`stop()` the node and (fresh start) `request_restart()` the app "to
guarantee cleanup" — even though the node was still loading weights or
downloading layers. This is the exact line in the incident log: `started
node failed inference readiness … Buzz is restarting`.

## Fix

Treat a **bound TCP port as alive**. Death has exactly one unambiguous
signal: a *closed* port.

- **Watchdog** (`recovery.rs`): only `PortClosed` may evict. A
bound-but-HTTP-unresponsive `Unhealthy` port is never evicted, at any
probe streak or urgency. Closed-port eviction is unchanged.
- **Start / restore** (`commands/mesh_llm.rs`): install the runtime
**before** probing readiness (so it is always tracked by `AppState` and
can never be orphaned — which is what the restart was guarding against),
and on a readiness timeout **leave it warming up** instead of
stopping/restarting. Launch-restoration stays disarmed until real
inference is confirmed, so a genuinely broken start is retried next
launch rather than silently disabling Share Compute.

### What this deliberately does *not* do

Detecting a node that is bound-but-internally-wedged needs a liveness
signal that bypasses the inference lock. There is none today, so this
fix cannot distinguish "wedged" from "busy" and errs toward not
restarting. That gap is a mesh-llm bug, filed upstream:
**Mesh-LLM/mesh-llm#1126** (lock-free `/live`+`/ready` on the ingress).
A follow-up here can consume it once it lands.

## Tests

- Watchdog never evicts a bound/busy port at any probe streak or urgency
(the regression).
- Closed-port eviction still fires (dead listener still reclaimed).
- Black-box: a listener that accepts TCP then stalls HTTP classifies as
`Unhealthy`, not `PortClosed`.
- **Mutation-proven**: reverting the eviction rule to the old
count-based logic fails the busy-node test.

`cargo test` (desktop, `--features mesh-llm`) green, fmt + clippy clean.

## Not covered here

The intermittent nature means I could not force the live loop
deterministically on a warm machine; the proof is the measured
serialization + the mutation-proven unit/black-box tests. Live behaviour
(app no longer restarts while a 26B node loads/serves) still merits a
manual check before merge.

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
2026-07-31 22:19:30 -04:00
Tyler
eb049ddf81 feat(desktop): Agent Trading Cards — mintable agent-snapshot card PNGs with optional NIP-44 lock (#3278)
## Agent Trading Cards

"Create Agent Card" action in the agent panel that mints an AI-generated
trading card PNG which **is** the agent: the card carries the
`buzz_agent_snapshot` tEXt chunk and is drag-in importable like any
snapshot PNG.

### What's in here
- **Mint pipeline (Rust):** one OpenAI Responses call — `gpt-5.6-sol` as
card designer with `gpt-image-2` via the `image_generation` tool (~2–3
min). New `mint_agent_card` / `save_agent_card` commands; preview with
reroll; save or send as `.agent.png` with round-trip verification before
any bytes leave the app.
- **Snapshot/chunk work stays in Rust,** reusing the existing
encoder/decoder seams (byte-compat golden vector proves the plain path
is identical to the pre-envelope encoder for placeholder, PNG-injection,
and JPEG-transcode paths).
- **Locked cards (NIP-44):** optional `buzz-agent-snapshot-encrypted`
envelope encrypted to the (owner, agent) pair. `parse_canonical_pubkey`
performs lift-x curve validation before any API spend; wrong-key decrypt
returns a fixed refusal; the plain decoder refuses locked cards.
- **Guardrails:** 10 MiB ceiling on final bytes, memory structurally
`none` in the snapshot, full-manifest import disclosure, API-key hygiene
via env layering (record > persona > global > process), fail-early
validation ordering (all key/lock/NIP-44-cap checks before Responses
spend).
- **Import side:** full-manifest disclosure dialog, locked-card import
disclosure, bounded avatar fetch.

### Review
Code reviewed by Wren across the full arc; final locked-card
cross-review **APPROVED 9/9/9** at exactly this head (`64f819dc8`), with
independent same-SHA verification: Rust lib 1,843/1,843, clippy
`--all-targets -D warnings`, desktop file-size gate.

### Live-mint evidence (real API, shipping seams, this SHA)
- **Plain (Honey):** 188s, 1500x2250, 5,101,503 bytes (< 10 MiB);
decoded manifest == built manifest; memory=none.
- **Locked (Fizz):** 176s, 4,670,184 bytes; owner-key and agent-key
decrypt both verified via logical manifest compare; wrong-key refusal
exact; plain decoder refuses.
- **Live finding:** built-in agents' ~171 KB inline avatars exceed the
NIP-44 65,535-byte plaintext cap and the fail-early guard fires before
API spend — clean error path, noted as a UX follow-up for large-avatar
agents choosing lock.

Full evidence (cards + dialog screenshots) posted in the originating
thread.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-07-31 22:01:17 -04:00
Wes
3a96acea09 chore(release): release Buzz Desktop version 0.5.3 (#3972)
## Buzz Desktop release v0.5.3

- **Frozen main:** `54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a`
- **Reviewed candidate:** `d0c06978bbf494ded6fe1a55d69d810ae9b65863`
- **Previous desktop release:** `v0.5.2`
- **Proposed immutable tag:** `desktop-v0.5.3`

This PR must be **squash merged** only after the Desktop Release
Candidate check passes. The branch must remain based directly on current
; stale base, payload drift, incomplete notes, or an unauthorized merge
produce no tag.

The checked-in changelog accounts for every non-merge commit in the
release range. Publication remains bound to the immutable candidate tag.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
2026-07-31 19:07:31 +00:00
Wes
bb34bc4d98 Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960)
Reverts block/buzz#3944
2026-07-31 17:38:42 +00:00
Wes
d12b3d6a79 chore(release): release Buzz Desktop version 0.5.3
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
2026-07-31 10:43:30 -06:00
John Matthew Tennant
c104eecfb3 feat(desktop): import local Pocket voices (#3259)
## Context

Pocket TTS currently offers bundled reference voices. People also need a
local, private way to add a voice without sending audio to a cloud
service.

## Summary

Add a Pocket voice import flow to Voice settings. Buzz opens the native
file picker, decodes common audio formats in the reusable `buzz-voice`
crate, canonicalizes the selected audio, stores it under a
content-derived identity in app data, selects it, and lets the user
delete it later.

## Changes

- Accept WAV, M4A, MP3, FLAC, OGG, and AIFF files between 2 and 30
seconds, including multichannel sources.
- Decode and downmix accepted audio to canonical mono 32 kHz PCM16 WAV
before hashing and storage.
- Store imported voices behind stable `pocket:imported:<sha256>`
identities and content-addressed files.
- Keep absolute file paths inside the native process and expose only
voice metadata to React.
- Include imported voices in Pocket preview and live huddle playback.
- Add Add voice and delete controls while preserving the bundled Pocket
voice catalog.
- Fall back to Mary when the selected imported voice is deleted.
- Keep durable import, selection, and deletion successful when a live
TTS worker acknowledgement is delayed.
- Preserve bundled voices when optional import metadata is unreadable
and keep failed deletion retryable.

## Related issue

None found.

## Testing

Production decoding was exercised with WAV, M4A with AAC, MP3, FLAC, OGG
Vorbis, and AIFF fixtures. Each format canonicalized to mono 32 kHz
PCM16 WAV. Manual validation in the combined daily-driver build covered
native-picker import, Preview, live-huddle playback, deletion, and Mary
fallback.

## Screenshots

The Voice settings card preserves the bundled Pocket catalog and adds
the local Add voice action.

![Pocket TTS voice
import](https://raw.githubusercontent.com/block/buzz/c03ba29060ca544c5ac3394c212f376651b386a3/pr-3259--pocket-voices.png)

## Reviewer-reproducible examples

Create common-format fixtures and run them through the production
importer:

```bash
. ./bin/activate-hermit
fixtures="$(mktemp -d)"
ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=220:duration=3" -ac 2 -ar 44100 "$fixtures/voice.wav"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a aac "$fixtures/voice.m4a"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.mp3"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.flac"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a libvorbis "$fixtures/voice.ogg"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a pcm_s16be "$fixtures/voice.aiff"
BUZZ_VOICE_IMPORT_TEST_DIR="$fixtures" \
  cargo test -p buzz-voice imports_common_audio_format_fixtures -- --ignored --nocapture
```

Exercise import persistence, synthesis, deletion, and bundled-voice
fallback with an installed Pocket model:

```bash
BUZZ_POCKET_MODEL_DIR=/path/to/pocket-model-bundle \
  cargo test -p buzz-voice --test pocket_import_audio \
  objective_import_synthesis_delete_and_mary_fallback \
  -- --ignored --nocapture
```

Exercise the native-picker boundary, selection, preview dispatch,
deletion, cancellation, and invalid-file states:

```bash
cd desktop
pnpm build:e2e
pnpm exec playwright test tests/e2e/voice-settings.spec.ts --project=smoke
```

---------

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <johnmatthewtennant@gmail.com>
Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: John Tennant <jtennant@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-07-31 11:15:09 -04:00
Tyler
61ba9dfaa0 refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
Relands **#2467** (extract `buzz-voice` crate) and **#3208** (Pocket
voice settings) onto main, after #3266 and #3180 merged.

## Why a fresh PR
The repo is squash-only with delete-branch-on-merge. Squashing #3266
deleted `jtennant/pocket-tts-2026-04`, which was #2467's base — GitHub
auto-closed #2467 and it cannot be reopened. Squash merges also sever
ancestry, so GitHub's natural merge-base reports phantom conflicts for
the whole remaining stack.

## Content provenance
- Byte-identical to the blessed `jt/buzz-voice-refactor` branch
(`93029c577`, tree `6729e0eff` — reviewed by Dawn (#2467) and Max
(#3208) at exact heads) **except** the three files where #3180 and #3208
genuinely interact.
- Three-file resolution (union of both sides):
- `huddle/mod.rs` — #3180's pipeline re-exports + #3208's
`agent_tts_routing` imports.
- `huddle/state.rs` — `reset_preserving_generation` preserves both
`huddle_generation` (#3180) and `tts_enabled` (#3208); test sets merged
into one `tests` module.
- `desktop/src/testing/e2eBridge.ts` — both switch arms kept; no
duplicate case labels.

## Verification at cf32dacd7
- `cargo test` (desktop/src-tauri, pinned 1.95.0): **2047 + 3 pass / 0
fail** (14 ignored: 8 keychain, 4 real_relay, 2 flag-gated)
- `cargo clippy --all-targets -- -D warnings`: clean; `cargo fmt
--check`: clean
- `cargo check --workspace` (root, includes new `buzz-voice` member):
clean; `cargo test -p buzz-voice`: 5/0
- `pnpm test`: **3885 / 0**; `tsc --noEmit`: clean; lint: clean

The 3180×3208 interaction resolution is getting an independent team
re-review before merge.

Buzz channel: buzz-desktop-voice `fd5fb402-b651-4238-89b1-bb3e2fa4dc96`,
thread `b4798ecc`.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-07-31 09:41:09 -04:00
John Matthew Tennant
4632c55041 feat(desktop): auto-enable huddle transcription for agents (#3180)
## Context

Before this change, every huddle initialized with transcription off.
Joining or adding an agent did not enable it, so the agent could not
receive spoken conversation until a person clicked the transcript
control. Starting a huddle from an agent DM could also omit that agent,
and adding an agent who already belonged to the parent channel could
attempt an unnecessary role change and show a warning.

Agent detection uses authoritative huddle membership. A participant
counts as an agent when the ephemeral membership identifies it with the
`bot` role, or when the existing agent identity model identifies the
participant in an agent DM.

## Summary

Buzz now enables transcription once when the first authoritative agent
is present. After that initial automatic action, explicit user control
is authoritative: manual ON or OFF survives membership refreshes,
reconnects, and UI remounts. Removing the last agent does not change the
current transcription state.

Agent-DM huddles enroll the agent automatically. Adding an agent who
already belongs to the parent channel preserves the existing parent role
and completes without a role-mutation warning.

| Scenario | Before | With this change |
| --- | --- | --- |
| First authoritative agent joins or is hydrated | Transcription stays
off | Transcription turns on once |
| User explicitly turns transcription on or off | Manual control exists
without an agent policy | The explicit choice suppresses later automatic
changes |
| Last agent leaves | No defined agent-presence behavior | The current
transcription state remains unchanged |
| Huddle starts from an agent DM | The agent can be omitted | The known
agent is enrolled automatically |
| Added agent already belongs to the parent channel | Buzz can attempt a
role rewrite and warn | Existing parent membership and role are
preserved |
| Transcription is active | The control is not visually distinct | The
control is highlighted and exposes `aria-pressed=true` |

## Changes

- Derive agent presence from authoritative bot-role huddle membership
and known agent-DM identity.
- Apply the one-time auto-enable rule during create, join, membership
hydration, reconnect, pipeline startup, and local agent addition.
- Preserve explicit user state and use huddle-generation guards so stale
asynchronous work cannot alter a replacement huddle.
- Keep backend and React transcription state synchronized, with a
visible and accessible active control.
- Enroll known agent-DM participants and make parent-channel membership
updates idempotent.
- Cover hydration ordering, reconnects, remounts, explicit OFF,
last-agent removal, DM enrollment, existing membership, and active
styling.

## Related issue

None found.

## Testing

Manual validation in `pending-seed` confirmed the product contract:

1. Started a huddle from the owned, running Fizz agent DM.
2. Confirmed the authoritative roster contained the human and Fizz as an
agent.
3. Confirmed transcription enabled without clicking the control: `Stop
transcript`, `aria-pressed=true`, with the highlighted active
background.
4. Turned transcription off and confirmed `Start transcript`,
`aria-pressed=false` remained stable.
5. Removed Fizz while transcription was off and confirmed the state
stayed off.
6. Left the huddle cleanly.

## Screenshots

The same control has distinct active and inactive states.

![Active transcript
control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--active-transcription.png)

![Inactive transcript
control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--inactive-transcription.png)

## Reviewer-reproducible examples

From a fresh checkout:

```bash
pnpm --dir desktop build:e2e
pnpm --dir desktop exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke
pnpm --dir desktop exec playwright test tests/e2e/mentions.spec.ts --project=smoke --grep "system agent profile exposes owned agent actions|system agent avatar exposes owned agent actions|owned bot profile exposes message and huddle actions|owned agent mention profile exposes message and huddle actions"
```

The huddle scenario exercises initial authoritative hydration, exactly
one automatic enable, explicit OFF persistence, unchanged state after
last-agent removal, newer events winning over delayed hydration,
agent-DM enrollment, and idempotent parent membership. It also asserts
`aria-pressed` and distinct computed active styling.

---------

Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-07-31 07:06:41 -04:00
Tyler
081f805d5e feat(agent): optional reply guard reminds a silent turn to publish (#3763)
## Why

A Buzz agent's assistant text and reasoning are never shown to anyone —
only what it posts through the CLI. A turn that runs fifteen tool calls
and never publishes is a silent failure: the requester waits on a result
that was produced and thrown away.

This adds an optional reminder at the end-of-turn gate, off by default.

Tyler asked for it in buzz-mesh; plan iterated to **9.5/10 with @Wren**
(Minimalness 9.7, Elegance 9.5, Correctness 9.3).

## What

`BUZZ_AGENT_REQUIRE_REPLY=1` (default off, per-agent opt-in). A turn
about to end with no recognized attempt to post gets a reminder and is
rerolled. **At most two, then the turn ends regardless** — the guard
catches accidental omission, it does not compel speech. The reminder
text explicitly licenses silence so it cannot fight the base prompt's
"silence is usually correct."

**This is not a new MCP hook.** `RunCtx::run` *is* the turn, so the two
per-turn locals need no plumbing, and every tool call already passes
through it with arguments visible. The objection is appended at the
existing `_Stop` gate and rides `push_hook_outputs_as_tool_results`, so
the model receives it as a lower-trust tool result with `{hook, server,
text}` attribution. No new trust path, no new lifecycle event, no
dev-mcp or CLI protocol change.

Earlier revisions of this plan needed four crates (a `_UserPromptSubmit`
hook, a marker file, a `buzz-cli` change, dev-mcp state). Tyler pointed
out the agent already knows both facts; that deleted all of it. Net
runtime change is ~35 lines in `agent.rs` + ~4 in `config.rs`.

### Recognition contract

A registered non-hook tool whose qualified name ends in `__shell`, whose
`command` argument contains `messages send` or `reactions add`.

- **The `__` separator is exact, not approximate.** Given `has()` +
`!is_hook()`, `ends_with("__shell")` is *provably equivalent* to a bare
name of `shell`: registration forbids `__` in server and bare names
(`mcp.rs:227,268`) and qnames are `{server}__{bare}`, so a trailing
`__shell` could only straddle the separator if the bare name began with
`_` — which `is_hook` excludes. Without the separator, `powershell` and
`noshell` would match.
- **Reads the structured `command` field**, not serialized arguments, so
a `description` that quotes a send cannot disarm the guard, and a
non-string `command` is rejected rather than coerced.
- **Detects an attempt, not a successful publish.** A failed send
already returns non-zero exit and error JSON — louder than this
reminder. The variable is named `buzz_reply_call_seen` so the code can't
pretend otherwise.
- **Checked after the per-turn tool-call cap**, since a discarded call
never ran.
- `messages send` also covers `messages send-diff`. Reactions count
because the base prompt directs agents to react rather than post a bare
acknowledgement.

**Known limits, both deliberate and documented:** a command assembled at
runtime (`$CMD`) or hidden in a wrapper script is missed; text that
merely quotes a send (`echo "buzz messages send"`) matches. Missing a
real post is the expensive direction and substring matching is the
forgiving one there. Neither edge is pinned by a test, so the matcher
stays free to improve.

### Budget

Reminders share `BUZZ_AGENT_STOP_MAX_REJECTIONS`, the existing outer cap
on every end-turn objection. Default 3 fits both; at 1 only one fits; at
0 the guard is off with the hooks. A round carrying both a hook
objection and a reminder costs one rejection and delivers both texts. An
independent budget would either violate that bound or need a second
arbitration rule.

## Prior art

- **#3467** (closed) built the same detector one layer up in `buzz-acp`
for a different remedy. None of its symbols are on main — this borrows
its permission to be coarse, but reads structured data that ACP didn't
have.
- **#3648** (open) detects turns with *no output at all*; a turn with
fifteen tool calls and no post counts as output there, so it does not
cover this case.
- **#3741** (merged) is mesh-only.

## Testing

**14 new tests.** 4 unit tests on the matcher; 10 integration tests
through the ACP wire harness: off by default, `=0` still off, opted-in
silent → exactly 2 reminders then `end_turn`, registered `fake__shell`
send → 0 reminders, hallucinated `fake__shell` → still reminded, publish
call truncated past the 64-call cap → still reminded, budget 1 → 1
reminder, budget 0 → off, combined `_Stop` hook objection + reminder →
one round both texts and after 2 reminders the hook objection continues
alone, unparseable `=true` → startup error naming the key.

**10 mutation checks, each breaking a specific named test** — neutralize
the nag cap, stop sharing the budget, neutralize `buzz_reply_call_seen`,
drop `has`/`is_hook`, ignore the flag, drop the `__`, drop `reactions
add`, read serialized args, move detection before truncation.

`tests/bin/fake_mcp.rs` gains `FAKE_MCP_SHELL_TOOL=1`: it previously
exposed no tool with a bare name of `shell`, so the satisfied-guard path
was untestable.

Full `cargo test -p buzz-agent` green at 9e0ae1f04; clippy `-D warnings`
and `cargo fmt --check` clean.

**Unrelated flake found:**
`cancelled_turn_with_usage_emits_notification_before_response`
(`tests/fake_llm.rs`) is timing-sensitive. Under 10 loaded cores it
fails **2/20 on this branch and 1/20 at unmodified
`origin/main@02be413b8`** — pre-existing, not caused by this change
(which is inert without the env var). Flagging so it isn't misattributed
to the next PR that's open when CI hits it.

## Docs

`crates/buzz-agent/README.md` is the primary home (env var, recognition
contract, limits, budget interaction). `docs/MCP_DRIVEN_HOOKS.md` gets a
short cross-reference explaining this is *not* a hook — otherwise
readers hunt for a `_ReplyGuard` tool that doesn't exist.

---------

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
2026-07-31 07:02:48 -04:00
John Matthew Tennant
d48b0e0eec feat(desktop): upgrade Pocket TTS model (#3266)
## Context

Buzz Desktop currently installs an older Pocket TTS model bundle. The
current
bundle changes the tokenizer, learned BOS input, recurrent-state
contract, and
prompt behavior, so updating download URLs alone is not compatible.

## Summary

This PR upgrades Buzz Desktop to the current pinned Pocket TTS model. It
preserves existing product behavior and the hard 50-token model-input
limit
while adding the required runtime support, verified acquisition, and
crash-safe
cache migration.

## Changes

- Pins an immutable Pocket TTS revision, artifact names, exact byte
sizes,
  SHA-256 checksums, Mary reference voice, and license.
- Loads the bundle-matched SentencePiece tokenizer, learned BOS
embedding, and
  bundle-declared recurrent states.
- Uses one pinned Pocket TTS configuration; no precision or
model-version
  selector is added.
- Preserves the resident engine's exact `<= 50` token contract without
changing
  Desktop segmentation policy.
- Bumps the Pocket cache manifest to v4, verifies size and checksum
before
adoption, atomically swaps the cache, and recovers the last verified
cache
  after interrupted installs, including an incomplete final directory.
- Keeps acquisition, cache migration, worker adoption, and tests within
the
  existing Desktop implementation.
- Removes the obsolete model-quality harness, which was coupled to the
  superseded production prompt and model layout.

## Related issue

None.

## Testing

Manual listening completed on the exact Desktop build. The updated model
improved speech quality and resolved the phrase-start and sample-onset
artifacts. Reproducible integrity and model checks are below.

## Screenshots

N/A. This changes model installation and speech synthesis, not a visual
surface.

## Reviewer-reproducible examples

### Before and after model identity

```sh
git show 35305bfc8fd456ca9a17caa1ddbfaabd87d46981:desktop/src-tauri/src/huddle/models.rs \
  | grep -E 'sherpa-onnx-pocket-tts|TTS_MODEL_VERSION'

git show 211d17c58567448fe7ac95c4fa0ad2b88378849a:desktop/src-tauri/src/huddle/pocket_models.rs \
  | grep -E 'MODEL_REPOSITORY|MODEL_REVISION|MODEL_PRECISION|MAX_TOKENS'
```

The target branch identifies the January bundle. The PR branch
identifies the
immutable April revision, INT8 precision, and 50-token maximum.

### Deterministic runtime validation

Use the pinned artifacts listed in `pocket_models.rs` and run the
model-dependent Pocket tests with the model directory supplied by the
test environment. The checked-in long-sentence fixture must preserve its
expected 48 and 44 token split and produce non-silent PCM.

### Manual listening validation

John listened to an untrimmed Pocket TTS onset-stress clip generated
from the exact user-provided passage, with every sentence synthesized
separately and identical 100 ms digital-silence boundaries. The clip
used no leading period, onset trimming, gain adjustment, or loudness
normalization.

The updated model produced better-quality speech and resolved the
start-of-sample artifacts.

---------

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: John Tennant <jtennant@block.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
2026-07-31 07:00:15 -04:00
Tyler
468647a51f feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
## Summary

Adds a locally stored **NIP-49 encrypted key backup** (`ncryptsec`) to
the desktop app, per the plan reviewed in buzz-development (Rev 3,
approved 9/10 by Wren; implementation also reviewed and approved 9/10).

**Two-artifact design — canonical bytes originate entirely in Rust:**
- `create_ncryptsec_backup` runs under the `identity_mutation` lock:
encrypt → decrypt-verify against the live pubkey → atomic `0o600` write
to `{app_data_dir}/identity.ncryptsec` → reread/byte-compare → return
the exact persisted bytes. The frontend never re-derives or re-encrypts.
- `save_ncryptsec_copy` writes a portable copy via the save dialog
(parse-gated, secret-file semantics) and never mutates canonical state.
- `generate_backup_passphrase`: 6 words from the EFF short wordlist via
`OsRng` (custom passphrases min 12 chars).
- Import accepts `ncryptsec1` with optional password; the raw-`nsec`
path is untouched. Different-pubkey import and sign-out wipe the
app-managed backup (post-commit, best-effort — a failed import can never
destroy the still-live identity's backup; regression-tested).

**Never-relay guarantee (egress guard + tripwires):**
- `egress_guard.rs` fail-closed at all 8 `/events` submission boundaries
(relay submit funnel, 3× `relay.rs`, huddle STT, both engram submitters,
native WS choke point), rejecting `ncryptsec1`/`NCRYPTSEC1` in text and
binary frames. Scope is deliberately ncryptsec-only: pairing
intentionally carries raw nsec inside its encrypted session.
- Site-granular `/events` inventory tripwire: per-file (`/events` count,
guard-call count) pairs; unlisted files expect zero. Mutation-style
tests prove a ninth site in an existing file, a removed guard, and a new
unlisted file all fail the scan.
- ncryptsec source-allowlist scans in **both** trees (Rust + TS).

**Frontend:** onboarding `BackupStep` is encrypted-by-default — the
default path never invokes `get_nsec` (e2e asserts the command log).
Raw-nsec export stays behind an explicit click with prior semantics.
Shared `EncryptedBackupCreator` powers onboarding + a new settings row;
the import form auto-switches to encrypted mode on `ncryptsec1` paste
(case-insensitive HRP).

**Open product call for @tlongwell-block:** onboarding default is
*encrypted* in this PR; flipping to raw-default is a small change either
way (documented in the plan).

Review history: plan Rev 3 and the implementation were both iterated
with Wren to 9/10 (two blockers from round 1 — import ordering,
inventory granularity — plus an uppercase-bech32 hardening gap, all
fixed in `dde37183e`). Thread: buzz-development.

### Related issue

Follow-up to the direction explored in #385 (NIP-PB, closed) — this
ships local NIP-49 (the standard) instead of a new NIP. No open
duplicate found.

### Testing

All at exactly `dde37183e` (same shell, HEAD verified):

- `cargo test` — 1680 passed / 0 failed / 14 ignored (includes a
deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password,
NFKC, uppercase-vector decrypt, injection test per egress boundary,
inventory mutation tests, import-ordering regression tests)
- `cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt
--check` — clean
- `pnpm typecheck` — clean; JS unit suite 3529/3529; biome (repo-pinned
2.4.16) clean
- Playwright `onboarding-backup` / `onboarding` /
`onboarding-agent-defaults` / `profile-nsec-reveal` — 86 passed, 1 known
avatar-reservation flake (passed on rerun; untouched by this diff).
`passThroughBackupStep` now exercises the encrypted default, so every
downstream onboarding spec covers the new path.
- Note: browser e2e fakes the crypto via the mock bridge (fixed
spec-vector blob); decryption correctness is proven in the Rust tests.

## Latest onboarding integration

The current head adds an additive `IdentityInfo.storage` field
(`ephemeral`, `system-keyring`, `local-file`, or `environment`) so
onboarding can accurately explain where the active identity is
protected. It surfaces storage metadata only—never key material—and
leaves the existing lost/keyring-locked recovery behavior intact.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-07-30 20:58:03 -04:00
Will Pfleger
36571f4adc fix(desktop): allow linux-only media items as dead code off-linux (#3811)
Local `desktop-tauri-clippy` fails on macOS with dead-code errors for
`PROD_ORIGIN`, `DEV_ORIGIN`, and `is_trusted_media_origin`, which are
only used inside `#[cfg(target_os = "linux")] enable_media_capture`. The
items are intentionally platform-independent so unit tests run
everywhere. Added `cfg_attr` allow attribute to suppress the warnings on
non-Linux targets.

Since [#3607](https://github.com/block/buzz/pull/3607), this affects all
Rust developers on macOS.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-07-30 18:11:03 -04:00
Will Pfleger
114d40d9d3 feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358)
Team catalog projections (`kind:30178`) embed every member's system
prompt, so they need the same read gate personas already have: only the
author sees an unshared event. The gate was hardcoded to `kind:30175` at
six read surfaces plus the SQL pushdown, so rather than adding a second
special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175,
30178}`.

## Kind 30178

New parameterized-replaceable kind, addressed by `(pubkey_o, 30178,
team_id)`. It embeds sanitized member projections instead of referencing
`kind:30175` heads — a foreign reader of a shared team could not
otherwise hydrate members whose own persona events are unshared or, for
built-ins, absent entirely. `kind:30176`'s wire body is untouched, so
device sync keeps its contract.

## Kind-generic shared gate

`buzz_core::kind` replaces `is_persona_shared_kind` /
`is_unshared_persona_event` / `persona_event_is_shared` with
`SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` /
`is_unshared_gated_event` / `event_is_shared`. Every read surface
consults the set:

| Surface | File |
|---|---|
| REQ historical delivery + `ids` lookup |
`crates/buzz-relay/src/handlers/req.rs` |
| Live fan-out | `crates/buzz-relay/src/handlers/event.rs` |
| COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` |
| NIP-98 HTTP `/query`, `/count`, `/search` |
`crates/buzz-relay/src/api/bridge.rs` |
| Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` |

The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)`
bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT`
so a page of newer private events cannot starve an older shared one off
the candidate set. `EventQuery::persona_reader` is renamed
`shared_gated_reader` and `needs_persona_filtering` to
`needs_shared_gate_filtering` to match.

Because the `buzz-core` rename has consumers outside the relay, the four
desktop call sites of `persona_event_is_shared` travel with it:
`desktop/src-tauri/src/commands/personas/pending.rs`,
`desktop/src-tauri/src/event_sync.rs`, and two in
`desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is
unchanged apart from the name — the persona `shared` projection behaves
exactly as before.

## Ingest validation

`validate_persona_envelope` splits into two reusable pieces —
`validate_shared_tag` (exactly-two-element `["shared","true"]`, at most
one occurrence) and `single_bounded_d_tag` (exactly one `d` tag,
non-empty, `<=64` chars, no ASCII control characters or whitespace).
`validate_team_catalog_envelope` composes both; personas additionally
keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.

`kind:30178` deliberately does **not** get the slug grammar. Team ids
are UUIDs or built-in identifiers such as `builtin-team:welcome`, and
the colon is not slug-legal; rewriting ids to fit would break NIP-33
addressing against the team's own `kind:30176` head. The non-empty and
exactly-one checks are load-bearing regardless — without them generic
NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every
team overwrites its predecessor.

The exact two-element `shared` shape is enforced because the SQL
visibility clause is JSONB containment (`tags @>
'[["shared","true"]]'`), which would match a three-element superset such
as `["shared","true","extra"]`.

`kind:30178` is also added to the `Scope::UsersWrite` allowlist and to
`is_global_only_kind`, so a stray `h` tag cannot channel-scope an
owner-authored definition.

## Deferred

`kind:30176` is deliberately not a gate member. Its writers never emit
`shared`, so catalog opt-in semantics do not describe it — it needs
owner-private reads driven by an authenticated principal set, tracked as
a separate follow-up.

## Tests

- 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and
colon `d` tags, 64-char boundary, non-ASCII bound,
empty/valueless/duplicate/missing `d`, embedded newline, `shared`
false/three-element/duplicate, scope and global-only membership).
- Persona regressions for the valueless `["d"]` shapes, since the
`d`-tag helper is shared by both validators.
- Existing `kind.rs` gate tests generalized and extended to assert the
gate applies to 30178 as it does to 30175.
- New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level
tests over a live relay covering author reads of unshared heads, foreign
omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and
unshare transitions, and the mixed-kind filter case.
- `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay
E2E job so the new suite runs.

## Docs

`docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178"
section and an "Ingest validation: kind:30178" subsection, records the
gate as kind-generic, documents 30178 deletion vs. unshare semantics,
and adds a security note that sharing a team exposes every member's
instructions even when that member's own `kind:30175` head is unshared.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-07-30 17:53:30 -04:00