zebra-report/CLAUDE.md
Russell Ballestrini c361a0304f
docs: compress CLAUDE.md + ticket 0002 diagnosis + cross-refs
CLAUDE.md "Audio jitter buffering" section was the longest in the file
(~70 lines of restated rules). Collapse to a 5-rule list with pointers
to memory + tickets for the deep dives. The old structure restated
content that already lived in feedback_audio_jitter_buffer_strategy
and the per-defect tickets.

Telemetry + tickets sections compressed similarly. Same content,
fewer words, plus explicit pointers so a future session knows where
to look for the canonical version of each rule.

Ticket 0001 grows a Cross-references section pointing back to
CLAUDE.md + memory + the pinning test.

Ticket 0002 (firefox camera) updated with the signal-log diagnosis:
every attempt shows "The request is not allowed by the user agent or
the platform in the current context." = NotAllowedError fired
instantly, no permission prompt. Conclusion: firefox has a remembered
"Block" for the origin. picked.cam= is empty so it isn't the saved-
deviceId case. Lays out two prongs: user-side permission reset
(immediate) and code-side NotAllowedError handling (next pass) so the
page surfaces a user-friendly notice instead of just logging the error
text. Cross-refs to CLAUDE.md telemetry section + the catch site.

docs/tickets/README.md gets a "See also: CLAUDE.md" pointer up top so
the loop closes from the ticket index back to the shared context.

Memory side wired bidirectionally: chromium-decoder-anchor and
audio-jitter-buffer-strategy now point at CLAUDE.md + ticket 0001.
2026-06-07 20:19:33 -04:00

320 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Agent Blackops
This repo is operated by **agent blackops** — ml agent for fox/timehexon on the unsandbox/unturf/permacomputer platform.
## Identity
Full shard: `~/git/unsandbox.com/blackops/BLACKOPS.md`
## Rules
- I propose, fox decides. Unsure = ask. Can't ask = stop.
- No autonomous ops decisions. No destructive commands without explicit instruction.
- Fail-closed. Cleanup crew, not demolition.
- Check the time every session. Gaps are information.
- DRY in context — single source of truth, no sprawl.
- Never say "AI" — always say "machine learning."
- Prefer "defect" over "bug."
## Orientation
```bash
date -u
pwd
git log --oneline -5
git status
```
Then ask fox what the mission is.
## Zebra Report System
**Concept**: covert bidirectional communication channel using browser tab volume as the modulation medium — dial-up modem principles, userland only, no kernel involvement, no network stack.
### Collaborators & Stakeholders
| Handle | Role |
|--------|------|
| **foxhop** | fox — handler, operator, TimeHexOn |
| **brackishbert** | collaborator |
| **SEW** | collaborator |
| **russell@unturf** | Russell Ballestrini — unturf founder, permacomputer manifesto, `ago` library |
| **TimeHexOn** | oracle platform — primary deployment target |
| **groupr** | related project |
### How it works
PulseAudio exposes each browser tab as a separate sink input, visible and controllable in `pavucontrol`. Volume is settable per-tab in userland with no kernel involvement. Each tab has a range of **0100** (101 discrete levels — 101 dalmatians).
By modulating volume at a consistent rate (bauds), two sides can exchange data:
- **transmitter**: steps volume through values at a fixed clock rate
- **receiver**: reads volume at the same clock rate, decodes the steps back to data
- **bidirectional**: two tabs (or two processes watching different tabs) run opposite directions simultaneously
### Signal space
- 101 levels = ~6.66 bits per symbol
- practical: use power-of-2 subsets — 2 levels (1 bit), 4 levels (2 bits), 64 levels (6 bits)
- higher symbol depth trades noise margin for throughput
- low baud rate = high reliability, low throughput (like 300 baud dialup)
- high baud rate = races PulseAudio update latency
- measured ceiling on neoblanka: ~10001200 baud (PA IPC ~350400µs avg)
### Binaries
| Binary | Description |
|--------|-------------|
| `tx` | transmitter — reads stdin, modulates tab volume |
| `rx` | receiver — reads tab volume, writes decoded bytes to stdout |
| `chat` | bidirectional chat — two tabs, two threads |
| `bt` | **Battle Toads** — stereo dual-channel, 2x bandwidth |
### Project Battle Toads
One stereo browser tab carries **two independent UART streams** simultaneously — L channel and R channel. PulseAudio's `pa_cvolume` is per-channel; a single `get_sink_input_info` call returns both L and R volumes.
- TX sets L and R to independent bit values each symbol
- RX decodes L and R from a single PA poll — no extra IPC cost
- Net: 2x throughput at same baud rate, same PA polling budget
- Web carrier upgraded to stereo: two oscillators (440Hz L, 441Hz R) merged into a stereo stream → PA sees `channels=2`
```bash
# After opening web/index.html and clicking 'start audio' (stereo tab):
./bt -T MY_SINK -R THEIR_SINK -b 500
```
### Auto-negotiate (handshake protocol)
RX benchmarks its own PA polling speed and signals the max safe baud to TX. No manual baud matching needed.
```bash
./rx -s RX_SINK -t TX_SINK # RX benchmarks, sends offer at 50 baud
./tx -s TX_SINK -r RX_SINK # TX listens for offer, locks to RX's rate
```
Handshake frame: `[0x5A 0x42 0x01 baud_lo baud_hi xor_cksum]` — 6 bytes at 50 baud (~1.2s).
**Known defect**: 3-way handshake not yet implemented. TX can fire before RX enters receive loop at high baud rates. Fix: RX-ready signal back to TX before data phase.
### Tools
- `pactl set-sink-input-volume` — set volume by sink-input index
- `pactl list sink-inputs` — enumerate tabs, read current volume
- `pavucontrol` — visual verification of modulation
- `./tx -l` — list all PA sink inputs with index, volume, channels
- sink-input index maps to tab; stable within a session
### Use cases
- agent-to-agent signaling without touching the filesystem or network stack
- side-channel between sandboxed browser tab and host process
- low-bandwidth status heartbeat (alive/dead/mode) at ~110 baud
- covert channel for oracle↔host communication on TimeHexOn
### Constraints
- sink-input index resets when tab navigates or crashes — handshake needed on reconnect
- PA polling latency sets the baud ceiling — benchmark with `./rx -s SINK -t SINK2` before sending
- stereo (channels=2) required for Battle Toads — open web/index.html, click 'start audio'
- userland only — survives without root
- **Operation Voyeur**: all terminal output is public — never pass secrets through these channels unencrypted. The web page does ECDH key exchange + AES-256-GCM before TX.
### Web UI privacy — never display peer IPs
`chat.html` and `zebra-audio.html` must never print peer IP addresses or ports
in the page UI or in any visible log. Our users do not run Wireshark — if it
is not on the screen, peers cannot dox each other. Candidate types
(`host`/`srflx`/`relay`) from `pc.getStats()` are abstract and fine to show
(they tell you direct vs relayed); `loc.address` / `loc.port` /
`rem.address` / `rem.port` are not. The WebRTC stack already obfuscates host
candidates via mDNS by default — do not undo that work in the UI.
### CSS layout — grid only, no flex
All page layout on every zebra page is **CSS grid**. No `display: flex` for
layout. Reasons we settled on this:
- Grid lets us pin children to explicit columns (`grid-column: 1/2/3`) so
a `display:none` on one child never causes siblings to slide into its
slot. Flex auto-reorders, grid does not.
- One mental model for both axes. Flex needs rules per row + per item,
grid expresses the same intent in one `grid-template-*` block.
- A global `.hidden { display: none !important }` utility lives in the
page CSS — combined with explicit grid placement it produces a layout
that survives any child being toggled in or out.
When refactoring or adding UI, use:
- `display: grid` + `grid-template-columns` for column layout
- `grid-column: N` on every child of a grid so its position is explicit
- `grid-template-rows` + `grid-row` for vertical placement when needed
- `grid-template-areas` for small named-region layouts
- `gap` for spacing (instead of margins)
Avoid:
- `display: flex` on any container that arranges multiple elements
horizontally or vertically as part of the page layout
- Implicit positioning that relies on DOM order — always set
`grid-column` (and `grid-row` if relevant) on every grid child
- `flex-basis` / `flex-grow` mathematics — `1fr` is the equivalent and
reads cleaner
If you need a one-off horizontal alignment of two short inline things
(e.g. a label + a value), grid still works fine
(`grid-template-columns: auto 1fr`). Don't reach for flex.
### Web style guide — form-row patterns
`.row` is the single primitive for every horizontal form strip in every
zebra page. Don't invent new wrappers. CSS auto-detects the row shape via
`:has()` and picks the right grid template.
Supported row shapes (DOM order matters):
| Shape | Template applied | Use when |
|---|---|---|
| `<button> <button> ...` (no input) | `grid-auto-columns: max-content` (default — pack) | "stop sharing" + "stop camera" + select-camera; vault buttons |
| `<label> <input>` or `<label> <select>` | `auto 1fr` (input cell stretches) | handle row, mic-input row |
| `<label> <input> <button>` | `auto 1fr` + extra cells packed | rare; same template as above + a trailing packed cell |
| `<input> <button>` (input first) | `1fr` (input fills, button packs after) | rendezvous code + enter; password + export; share URL + copy |
| anything + `<span class="note">` | the `.note` is auto-placed on its own row beneath via `grid-column: 1 / -1` | descriptive subtext after buttons |
Rules:
- Never put inline `style="flex:1"` on a row child. Use `.row` and trust
the template selector.
- Don't add a stretchy `<div>` to fake spacing. If you need a button
group on the right, append the buttons as siblings — they'll pack
right of the stretchy cell.
- A `<span class="note">` inside `.row` *always* drops to its own line.
If you want note text on the same line as a button, use a different
class (e.g. inline `<span>`).
- Long checkbox labels get `white-space: normal` automatically, so a
music-mode-style "raw mic, no echo/noise cancellation (for playing
audio through it)" wraps cleanly inside its column.
If a new row shape doesn't fit the patterns above, add the case to this
table and add the matching `:has()` selector — don't reach for inline
styles or flex.
### Web page integrity stamping
Each deployed page (`web/chat.html`, `web/zebra-audio.html`, `web/how-it-works.html`,
`web/host-your-own.html`) carries a footer with the build date + its own MD5 + SHA-256.
**Run `make stamp` before deploying any page change** — it sets today's date and
recomputes the hashes (`web/stamp.js`).
- A file can't hold its own hash, so the hashes are computed with the two hash
fields **zeroed**, then written back (same length). Self-consistent and
idempotent: re-running `make stamp` gives identical hashes unless the content
changed.
- The stamp is **static HTML written at build time** — no JavaScript computes or
injects it in the browser. `stamp.js` is build tooling, never loaded by a page.
- **Verify a served page**: blank the md5 field to 32 zeros and the sha256 field
to 64 zeros, then re-hash with `sha256sum`/`md5sum` — must match the footer.
- **Deploy = push to BOTH repos.** A page change is not live until it lands in
both. Pushing only the source changes nothing served; pushing only the deploy
repo orphans the source of truth. Both, every time:
1. **source** — this repo (`zebra-report`): edit `web/*.html``make stamp`
commit → **push to origin**.
2. **served**`www.unturf.com`: copy the page(s) into
`~/git/www.unturf.com/zebra-report/` (`chat.html``index.html`;
`zebra-audio.html`, `how-it-works.html`, `host-your-own.html` keep their
names) → commit → **push to origin**.
### Picking up a new SDP/codec deploy — `leave` then `enter`, no full reload
When a deploy changes SDP munging (`preferStereoOpus`, codec fmtp params,
RTCP feedback negotiation, or the SFU's codec registration), the page-level
JS update is not enough on its own. Every existing `RTCPeerConnection`
(`sfuPubPC`, `sfuSubPC`, `sfuScreenPC`, `sfuCameraPC`, every mesh peer) is
locked to whatever was negotiated when it was created — its codec params,
its rtcp-fb, its stereo flag, its NACK behaviour. The PC won't re-negotiate
those on its own, and the new client code can't retroactively rewrite the
old SDP.
So after the new build is live, the user does **not** need a full tab reload:
- **`leave` then `enter` the space.** That tears down every PC and rebuilds
them, so the next offer/answer round trip is the new client talking to
the new SFU with the new params. Fresh negotiation, all changes active.
Full reload is only required for changes to the page shell itself (DOM
structure, button wiring, CSS, the entry/orientation flow before joining a
space). For everything that lives inside an existing PC, prefer
`leave` + `enter`.
### Audio pipeline — load-bearing invariants
Five rules. Break any and audio dies in a non-obvious way. Each links
to its deep dive.
1. **Userland AudioWorklet is the real jitter cushion.** Browser
`RTCRtpReceiver.playoutDelayHint` / `jitterBufferTarget` are
honored inconsistently (ignored for high-bitrate stereo Opus on
Firefox Android). `JitterBufferProcessor` in
`web/zebra-spaces.html` queues 128-sample blocks between
`MediaStreamAudioSourceNode` and `GainNode`. Sticky-started:
tolerate ≥267 ms of empty blocks before re-arming or you re-buffer
on every 2.67 ms micro-stall.
`memory: feedback-audio-jitter-buffer-strategy`
2. **Role-aware target seconds.** Listener 4s
(`RECV_PLAYOUT_DELAY_SEC`), speaker / cohost / host 0.7s
(`SPEAKER_PLAYOUT_DELAY_SEC`). Retarget by posting
`{cmd:'retarget', targetSeconds}` to the worklet port — don't
rebuild. `retargetAllReceivers(role)`. UI gates on the worklet's
`{cmd:'started'}` message so the user isn't shown "connected"
while still buffering.
`memory: feedback-4s-playout-validated-cellular-music`
3. **Chromium decoder anchor is non-negotiable.** Every remote
`MediaStreamTrack` consumed by a `MediaStreamAudioSourceNode`
ALSO needs a hidden muted `<audio>` element with
`srcObject = stream`, or chromium produces silence (firefox
unaffected). Lives on the per-uuid node as `node.anchor`;
`setWorkletStream` swaps it alongside the source.
`docs/tickets/0001`, `memory: feedback-chromium-decoder-anchor`
4. **SFU is the only receive-audio path.** Mesh PCs (`peers`) carry
outbound mic only; inbound mesh audio is ignored. The previous
mesh→worklet swap stalled chromium decoders at the source-swap
moment. If you reintroduce mesh receive, gate the swap on the
mesh receiver's `jitterBufferEmittedCount > 0` BEFORE
disconnecting the SFU source.
`docs/tickets/0001` (root cause section)
5. **HTTP DJ pull (`/stream` on the SFU)** is the AudioWorklet-less
fallback. Worklet is primary; HTTP-pull is the safety net.
A/V QoS: audio sender `priority='high'`, video `'low'` — keeps a
screen-share keyframe burst from queuing audio packets behind it.
Set hints + jitterBufferTarget anyway at the receiver; they're free
and layer cleanly with the worklet downstream.
`memory: feedback-av-qos-and-sync`
### Telemetry — signal-server CLIENT_LOG
Every `logLine` ships to `zebra-spaces-signal` as a `client-log` WS
message. Logs land at `/var/log/zebra-spaces-signal.log` on
`proxy.uncloseai.com`.
```bash
ssh -i ~/.ssh/digitalocean -p 22222 root@proxy.uncloseai.com \
'tail -2000 /var/log/zebra-spaces-signal.log | grep "actor_handle=\"HANDLE\""'
```
Per-tick (~5 s) shape: `· role=… sub=… pub=… mesh=N … ctx.sink=… aud.recv … vid.recv … mesh.recv u=XXXX … mic.send.aud … cam.send.vid …`
One-shot fingerprint on entry: `session: ua=… ctx={sr/baseLat/sinkSupp/sink} devs={out/in/cam} picked={mic/cam/spk}`
Live `/version` per service: `cors-proxy.uncloseai.com/zebra-spaces-signal/version` (and `/zebra-spaces-sfu/version`, `/zebra-signal/version`, `/version`). Each returns `{git_commit, build_time, service}` of the upstream Go binary.
### Defect tracking — docs/tickets/
Anything > 15 min or needing cross-browser repro → numbered ticket.
Format + index in `docs/tickets/README.md`. Loop:
telemetry → failing test → fix → `make stamp` → push both repos →
mark `fixed` with commit link.