commit b77da42bbeaffb6f78b1a7cf03cf61b570c9f791 Author: Russell Ballestrini Date: Wed May 27 13:51:14 2026 -0400 phase 1: unfirehose reconstruction from session JSONL ingest Source: ~/.unfirehose/unfirehose.db (project_id=81, 4 sessions covering 2026-03-29 through 2026-04-05). Reconstructed via chronological replay of Write/Edit tool_input on file_paths under /home/fox/zebra-report/. stats: files reconstructed: 20 writes baselined: all (zero missing) edits applied: 68 edits unapplied: 8 (1 SKIP pre-baseline, 6 FAIL old_string drift, 1 AMBIGUOUS) unapplied edits represent small drift in 6 files; baseline content for each is intact. quality verification deferred to phase 2. recovered tree: CLAUDE.md, Makefile src/{tx,rx,pulse,carrier,chat,bt}.c include/{modem,zebra}.h test/{functional,integration,unit}.c, test/test.h web/{index,kernel}.html, web/blog/style.css blog/build.py, blog/posts/{001-volume-modem,002-sse-chatroom}.md report: /tmp/zebra_recover_report.txt script: /tmp/zebra_recover.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6baa0ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# 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 **0–100** (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: ~1000–1200 baud (PA IPC ~350–400µ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 ~1–10 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f6f2a1 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +CC = gcc +CFLAGS = -Wall -Wextra -O2 -Iinclude $(shell pkg-config --cflags libpulse) +LDFLAGS = $(shell pkg-config --libs libpulse) -lrt -lpthread + +PULSE = src/pulse.c + +.PHONY: all clean serve blog test test-all + +all: tx rx chat bt carrier + +test: test/unit + @./test/unit + +test-all: test/unit test/integration test/functional + @echo "--- unit ---" + @./test/unit + @echo "--- integration ---" + @./test/integration + @echo "--- functional ---" + @./test/functional + +test/unit: test/unit.c include/zebra.h include/modem.h test/test.h + $(CC) $(CFLAGS) -o $@ test/unit.c + +test/integration: test/integration.c src/pulse.c include/zebra.h include/modem.h test/test.h + $(CC) $(CFLAGS) -o $@ test/integration.c src/pulse.c $(LDFLAGS) + +test/functional: test/functional.c src/pulse.c include/zebra.h include/modem.h test/test.h + $(CC) $(CFLAGS) -o $@ test/functional.c src/pulse.c $(LDFLAGS) + +tx: src/tx.c $(PULSE) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +rx: src/rx.c $(PULSE) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +chat: src/chat.c $(PULSE) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +bt: src/bt.c $(PULSE) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +carrier: src/carrier.c + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +blog: + python3 blog/build.py + +serve: blog + cd web && python3 -m http.server 8765 + +clean: + rm -f tx rx chat bt carrier test/unit test/integration test/functional + rm -rf web/blog/001-volume-modem web/blog/002-sse-chatroom web/blog/index.html diff --git a/blog/build.py b/blog/build.py new file mode 100644 index 0000000..b43f3c7 --- /dev/null +++ b/blog/build.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +blog/build.py — static blog generator for zebra-report +markdown + frontmatter → html, no dependencies beyond stdlib +""" + +import re, html +from pathlib import Path + +POSTS_DIR = Path("blog/posts") +OUT_DIR = Path("web/blog") + +# ------------------------------------------------------------------ # +# minimal markdown → html # +# ------------------------------------------------------------------ # + +def md_to_html(text): + lines = text.split("\n") + out = [] + in_code = False + in_list = False + buf = [] + + def flush_para(): + if buf: + content = inline(" ".join(buf).strip()) + if content: + out.append(f"

{content}

") + buf.clear() + + def flush_list(): + nonlocal in_list + if in_list: + out.append("") + in_list = False + + def inline(s): + s = html.escape(s, quote=False) + s = re.sub(r"`(.+?)`", r"\1", s) + s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) + s = re.sub(r"\*(.+?)\*", r"\1", s) + s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', s) + return s + + for line in lines: + # fenced code blocks + if line.startswith("```"): + if in_code: + out.append("") + in_code = False + else: + flush_para(); flush_list() + lang = line[3:].strip() + out.append(f'
' if lang else "
")
+                in_code = True
+            continue
+        if in_code:
+            out.append(html.escape(line))
+            continue
+
+        # headings
+        m = re.match(r"^(#{1,3})\s+(.*)", line)
+        if m:
+            flush_para(); flush_list()
+            n = len(m.group(1))
+            out.append(f"{inline(m.group(2))}")
+            continue
+
+        # unordered list
+        m = re.match(r"^[-*]\s+(.*)", line)
+        if m:
+            flush_para()
+            if not in_list:
+                out.append("
    ") + in_list = True + out.append(f"
  • {inline(m.group(1))}
  • ") + continue + + # blank line + if not line.strip(): + flush_para(); flush_list() + continue + + buf.append(line) + + flush_para(); flush_list() + return "\n".join(out) + +# ------------------------------------------------------------------ # +# frontmatter parser # +# ------------------------------------------------------------------ # + +def parse_post(path): + text = path.read_text() + meta = {} + body = text + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + for line in parts[1].strip().splitlines(): + if ":" in line: + k, v = line.split(":", 1) + meta[k.strip().lower()] = v.strip() + body = parts[2] + meta["content"] = md_to_html(body.strip()) + meta.setdefault("slug", path.stem) + meta.setdefault("date", "") + meta.setdefault("title", path.stem) + meta.setdefault("summary", "") + return meta + +# ------------------------------------------------------------------ # +# templates # +# ------------------------------------------------------------------ # + +CSS_LINK = '' + +BASE = """\ + + + + + {title} — zebra report + {css} + + + +
    {body}
    + +""" + +def render_post(meta, newer=None, older=None): + nav = [] + if older: + nav.append(f'← {older["title"]}') + if newer: + nav.append(f'{newer["title"]} →') + nav_html = f'' if nav else "" + + body = f"""
    +

    {html.escape(meta["title"])}

    +

    {html.escape(meta["date"])} — all posts

    +
    {meta["content"]}
    + {nav_html} +
    """ + return BASE.format(title=html.escape(meta["title"]), css=CSS_LINK, body=body) + +def render_index(posts): + items = "" + for p in posts: + items += f"""
  • + + {html.escape(p["title"])} +

    {html.escape(p["summary"])}

    +
  • \n""" + body = f'

    posts

    \n
      \n{items}
    ' + return BASE.format(title="zebra report", css=CSS_LINK, body=body) + +# ------------------------------------------------------------------ # +# build # +# ------------------------------------------------------------------ # + +def build(): + OUT_DIR.mkdir(parents=True, exist_ok=True) + + posts = [parse_post(p) for p in sorted(POSTS_DIR.glob("*.md"))] + posts.sort(key=lambda p: p["date"], reverse=True) + + for i, post in enumerate(posts): + newer = posts[i - 1] if i > 0 else None + older = posts[i + 1] if i + 1 < len(posts) else None + d = OUT_DIR / post["slug"] + d.mkdir(exist_ok=True) + (d / "index.html").write_text(render_post(post, newer=newer, older=older)) + print(f" {post['slug']}/") + + (OUT_DIR / "index.html").write_text(render_index(posts)) + print(" index.html") + print("done.") + +if __name__ == "__main__": + build() diff --git a/blog/posts/001-volume-modem.md b/blog/posts/001-volume-modem.md new file mode 100644 index 0000000..33bf6fa --- /dev/null +++ b/blog/posts/001-volume-modem.md @@ -0,0 +1,78 @@ +--- +Title: #001: the volume modem +Date: 2026-03-29 +Slug: 001-volume-modem +Summary: PulseAudio exposes every browser tab as a named sink input. Volume is a signal. 101 dalmatians. We made a modem. +--- + +## the discovery + +PulseAudio exposes each browser tab as a separate sink input. pavucontrol shows them. You can set them individually. In userland. No root. No kernel module. + +That means every open tab is a controllable signal source. 0 to 100. 101 discrete levels — 101 dalmatians. + +That means you can modulate at consistent baud rates. That means you have a modem. + +## the channel + +The signal space has 101 levels but we use two — far apart for noise margin. + +- **MARK** (idle, logic-1): 80% +- **SPACE** (start, logic-0): 20% +- **threshold**: 50% + +Wire format is UART. Start bit + 8 data bits LSB-first + stop bit = 10 symbols per byte. At 10 baud that is 1 byte per second. Slow. But it works. + +The receiver runs at 2x oversample. It watches for a MARK→SPACE falling edge, advances to the center of the start bit, then samples data bits at full-period intervals. + +## the carrier + +A `