Video calls (PairUX→ASCII), agent@ chat, and finger routes

ssh video-<code>@ joins a PairUX/LiveKit call rendered as truecolor
ASCII (▀ half-blocks, 2 pixels per cell); video@ prompts for a code.
Codes are minted by PairUX only. Pipeline: VP8 RTP → PLI keyframe
requests → ivfwriter remux → ffmpeg decode/scale → RGB24 → ANSI →
bubbletea over the SSH PTY. Subscriber-only, no audio in v1.

ssh agent@ opens a persisted chat with the operator's agent —
AGENTBBS_AGENT_CMD runs per message (stdin→stdout), e.g. `claude -p`.

ssh <member>@ with someone else's name prints a classic finger card
(.plan, member since, last seen) and disconnects; your own name still
enters the hub.

cmd/lkpublish: dev publisher for testing (explicit -fps pacing; lksdk
IVF replay mispaces from file timebase alone, measured 1fps from a
15fps file; dimensions required or dynacast pauses the track).

Verified end-to-end against livekit-server --dev: 128k truecolor cells
/ 20k distinct colors streamed over a real SSH session; chat round-trip
and finger card verified over SSH. Go toolchain pinned to 1.26 via
mise.toml (lksdk requirement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-11 11:43:38 +00:00
parent f3b085a08f
commit 9b0f465946
14 changed files with 1397 additions and 46 deletions

View file

@ -4,15 +4,18 @@
by Profullstack, Inc.
```bash
ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome
ssh join@profullstack.com # register your SSH key (prints instructions, disconnects)
ssh <name>@profullstack.com # the hub as a member: saves, your own WADs, leaderboards
ssh pod@profullstack.com # your own Linux pod — members, $1/mo via CoinPay
ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome
ssh join@profullstack.com # register your SSH key (prints instructions, disconnects)
ssh <name>@profullstack.com # the hub as a member — or finger someone else's name
ssh pod@profullstack.com # your own Linux pod — members, $1/mo via CoinPay
ssh video-<code>@profullstack.com # join a PairUX video call as truecolor ASCII
ssh agent@profullstack.com # chat with the operator's AI agent
```
No browser, no install, no client download. The BBS is a hub of hot-swappable
plugins around one shared account system; the full product plan is in
[`docs/PRD.md`](docs/PRD.md) and [`docs/pods.md`](docs/pods.md).
[`docs/PRD.md`](docs/PRD.md), [`docs/pods.md`](docs/pods.md),
[`docs/video.md`](docs/video.md), and [`docs/social.md`](docs/social.md).
## Status
@ -21,6 +24,8 @@ plugins around one shared account system; the full product plan is in
| M0 — core hub (wish server, auth, plugin contract, SQLite) | ✅ |
| M1 — arcade (doom-ascii + Freedoom, sandbox, saves, leaderboards) | ✅ |
| Pods (`pod@`, rootless containers, CoinPay membership) | ✅ |
| Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) | ✅ |
| `agent@` chat (configurable agent backend) + finger | ✅ |
| M2 — admin console | ⬜ |
| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | ⬜ |
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |

View file

@ -37,6 +37,8 @@ import (
gossh "golang.org/x/crypto/ssh"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/payments"
"github.com/profullstack/agentbbs/internal/plugin"
@ -138,11 +140,18 @@ func (a *app) router() wish.Middleware {
hubHandler := activeterm.Middleware()(btMw(next))
return func(s ssh.Session) {
user := strings.ToLower(s.User())
code, isVideo := calls.RouteCode(user)
switch {
case auth.IsJoinName(user):
a.handleJoin(s)
case auth.IsPodName(user):
a.handlePod(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":
a.handleChat(s)
case a.handleFinger(s, user):
// fingered an existing account that isn't the caller's; done.
default:
hubHandler(s)
}
@ -280,6 +289,80 @@ func (a *app) handlePod(s ssh.Session) {
}
}
// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
// minted by PairUX — starting a call requires already having one.
func (a *app) handleVideo(s ssh.Session, code string) {
identity := "ssh-guest"
if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
if u, found, _ := a.st.UserByFingerprint(fp); found {
identity = "ssh-" + u.Name
}
}
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "video")
defer func() { _ = a.st.EndSession(sessID) }()
if err := calls.Handle(s, code, identity); err != nil {
wish.Println(s, "video: "+err.Error())
}
}
// handleChat is the agent@ surface: talk to the operator's agent.
func (a *app) handleChat(s ssh.Session) {
u := auth.User{Name: "guest-" + remoteIP(s), Kind: auth.Guest}
if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
if su, found, _ := a.st.UserByFingerprint(fp); found {
u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
}
}
sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "agent")
defer func() { _ = a.st.EndSession(sessID) }()
if err := chat.Handle(s, a.st, u); err != nil {
wish.Println(s, "chat: "+err.Error())
}
}
// handleFinger prints a classic finger card when someone ssh's to an
// existing account name that isn't their own (e.g. ssh anthony@host).
// Returns false when the route should fall through to the hub.
func (a *app) handleFinger(s ssh.Session, username string) bool {
if auth.IsGuestName(username) {
return false
}
u, found, err := a.st.UserByName(username)
if err != nil || !found {
return false // unclaimed name → hub (claim flow)
}
if fp := auth.Fingerprint(s.PublicKey()); fp != "" && fp == u.PubKeyFP {
return false // it's them → hub
}
lastSeen := "never"
if t, ok, _ := a.st.LastSeen(u.ID); ok {
lastSeen = t.Local().Format("2006-01-02 15:04 MST")
}
plan := "no plan."
for _, p := range []string{
filepath.Join(a.dataDir, "users", u.Name, ".plan"),
filepath.Join(a.dataDir, "users", u.Name, "plan.txt"),
} {
if b, err := os.ReadFile(p); err == nil {
plan = strings.TrimSpace(string(b))
break
}
}
_, _ = a.st.RecordSession(0, s.User(), remoteIP(s), "finger")
wish.Println(s, strings.Join([]string{
"",
" Login: " + u.Name + " Kind: " + u.Kind,
" Member since: " + u.CreatedAt.Format("2006-01-02") + " Last seen: " + lastSeen,
" Plan:",
" " + strings.ReplaceAll(plan, "\n", "\n "),
"",
}, "\n"))
_ = s.Exit(0)
return true
}
func grantPod(st store.Store, args []string) {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: agentbbs grant-pod <username> <months>")

68
cmd/lkpublish/main.go Normal file
View file

@ -0,0 +1,68 @@
// Command lkpublish is a dev tool: it publishes a VP8 IVF file into a
// LiveKit room so the video-<code>@ SSH route has something to render.
//
// ffmpeg -f lavfi -i testsrc=size=320x240:rate=15 -t 60 -c:v libvpx test.ivf
// go run ./cmd/lkpublish -room test123 -file test.ivf
package main
import (
"flag"
"os"
"os/signal"
"time"
"github.com/charmbracelet/log"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func main() {
room := flag.String("room", "test123", "room/call code to publish into")
file := flag.String("file", "test.ivf", "VP8 IVF file to stream")
fps := flag.Int("fps", 15, "frame rate to pace the file at")
flag.Parse()
url := env("LIVEKIT_URL", "ws://localhost:7880")
key := env("LIVEKIT_API_KEY", "devkey")
secret := env("LIVEKIT_API_SECRET", "secret")
r, err := lksdk.ConnectToRoom(url, lksdk.ConnectInfo{
APIKey: key,
APISecret: secret,
RoomName: *room,
ParticipantIdentity: "lkpublish",
}, &lksdk.RoomCallback{})
if err != nil {
log.Fatal("connect", "err", err)
}
defer r.Disconnect()
// Explicit frame duration: don't trust IVF timebase interpretation.
track, err := lksdk.NewLocalFileTrack(*file,
lksdk.ReaderTrackWithFrameDuration(time.Second/time.Duration(*fps)))
if err != nil {
log.Fatal("track", "err", err)
}
// Width/height/source matter: without dimensions the SFU's dynacast has
// no layer to subscribe to and pauses the track entirely.
if _, err := r.LocalParticipant.PublishTrack(track, &lksdk.TrackPublicationOptions{
Name: "test-pattern",
Source: livekit.TrackSource_CAMERA,
VideoWidth: 320,
VideoHeight: 240,
}); err != nil {
log.Fatal("publish", "err", err)
}
log.Info("publishing", "room", *room, "file", *file)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
<-sig
}

41
docs/social.md Normal file
View file

@ -0,0 +1,41 @@
# Social Routes Addendum: agent@ and finger
## agent@ — talk to the operator
```bash
ssh agent@profullstack.com
```
A chat TUI: visitors talk to the operator's AI agent. Every message (both
directions) is persisted to `chat_messages`, so the operator can read
conversations later; live operator takeover arrives with the M2 admin
console.
The agent backend is one command, configured by env:
| Var | Meaning |
|---|---|
| `AGENTBBS_AGENT_CMD` | command per message: user text on stdin, reply on stdout (120s timeout). e.g. `claude -p`, a logicsrc/commandboard agent, any script. Unset = "message saved for the operator" mode. |
Identity: if the visitor's SSH key matches a member, the transcript is tied
to their account; otherwise it's keyed by remote address as a guest.
## finger — `ssh <member>@`
SSH'ing to an account name that exists but **isn't yours** prints a classic
finger card and disconnects:
```
$ ssh anthony@profullstack.com
Login: anthony Kind: member
Member since: 2026-06-11 Last seen: 2026-06-11 11:07 UTC
Plan:
building agentbbs.
```
- The plan is read from the member's `~/.plan` (or `plan.txt`) in their data
directory.
- Your own name (key matches) still lands you in the hub; unclaimed names
fall through to the hub's claim flow.
- Works keyless — like real finger.

64
docs/video.md Normal file
View file

@ -0,0 +1,64 @@
# Video Calls Addendum (PairUX over SSH)
Join a PairUX video call from a terminal: the platform subscribes to the
call's LiveKit room and renders participant video as **truecolor ASCII**
each character cell is two pixels via the upper-half block (▀), the same
technique doom-ascii uses.
## SSH routes
| Command | What happens |
|---|---|
| `ssh video-<code>@profullstack.com` | join call `<code>` directly |
| `ssh video@profullstack.com` | prompted for a code |
**Codes are minted by PairUX.** The SSH surface never creates calls — to
start one you must already have a code (create the call in PairUX first).
## Pipeline
```
PairUX / LiveKit room
└─ VP8 RTP track ── PLI keyframe requests ──┐
└─ ivfwriter remux → ffmpeg (decode + scale) → RGB24 frames
└─ half-block ANSI (internal/ascii) → bubbletea → SSH PTY
```
- Subscriber-only: the terminal viewer publishes nothing.
- A PLI is sent on subscribe and every 2s — without it the SFU never starts
forwarding video to a fresh subscriber, and the periodic refresh bounds
packet-loss artifacts.
- Audio is not rendered (it's a terminal); v2 could downlink Opus → local
audio out for desktop SSH clients.
- Frame size locks to the terminal geometry at join (`scale=` in ffmpeg);
~1015 fps at typical terminal sizes costs a few hundred KB/s of SSH
bandwidth.
## Configuration
Shares PairUX's env shape:
| Var | Meaning |
|---|---|
| `AGENTBBS_LIVEKIT_URL` (or `LIVEKIT_URL` / `NEXT_PUBLIC_LIVEKIT_URL`) | LiveKit ws URL |
| `AGENTBBS_LIVEKIT_KEY` (or `LIVEKIT_API_KEY`) | API key |
| `AGENTBBS_LIVEKIT_SECRET` (or `LIVEKIT_API_SECRET`) | API secret |
| `AGENTBBS_VIDEO_DEBUG` | dump the received IVF stream to this path |
Unconfigured hosts refuse with a clear message.
## Dev testing
```bash
docker run -d -p 7880:7880 -p 7881:7881 -p 7882:7882/udp \
livekit/livekit-server --dev --bind 0.0.0.0 # devkey/secret
ffmpeg -f lavfi -i testsrc=size=320x240:rate=15 -t 900 \
-c:v libvpx -b:v 400k -g 15 -y test.ivf
go run ./cmd/lkpublish -room demo1 -fps 15 -file test.ivf
ssh -p 2222 video-demo1@localhost
```
Note: `lkpublish` passes an explicit frame duration — lksdk's IVF replay
pacing can't be trusted from the file timebase alone (we measured 1fps from
a 15fps file without it). Real PairUX publishers are browsers, which pace
correctly on their own.

91
go.mod
View file

@ -1,6 +1,6 @@
module github.com/profullstack/agentbbs
go 1.25.0
go 1.26
require (
github.com/charmbracelet/bubbletea v1.3.10
@ -9,41 +9,110 @@ require (
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/creack/pty v1.1.24
golang.org/x/crypto v0.37.0
github.com/livekit/server-sdk-go/v2 v2.16.6
github.com/pion/webrtc/v4 v4.2.15
golang.org/x/crypto v0.50.0
modernc.org/sqlite v1.52.0
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect
buf.build/go/protovalidate v1.1.2 // indirect
buf.build/go/protoyaml v0.6.0 // indirect
cel.dev/expr v0.25.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
github.com/charmbracelet/x/input v0.3.4 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.0 // indirect
github.com/charmbracelet/x/windows v0.2.0 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gammazero/deque v1.2.1 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 // indirect
github.com/livekit/protocol v1.46.0 // indirect
github.com/livekit/psrpc v0.7.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/magefile/mage v1.17.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/ice/v4 v4.2.7 // indirect
github.com/pion/interceptor v0.1.45 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.2 // indirect
github.com/pion/sctp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.11 // indirect
github.com/pion/stun/v3 v3.1.5 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/turn/v5 v5.0.9 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.24.0 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

329
go.sum
View file

@ -1,11 +1,43 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0=
buf.build/go/protovalidate v1.1.2/go.mod h1:Ez3z+w4c+wG+EpW8ovgZaZPnPl2XVF6kaxgcv1NG/QE=
buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@ -16,85 +48,322 @@ github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+ts
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.0.0+incompatible h1:KgsN2RUFMNM8wChxryicn4p46BdQWpXOA1XLGBGPGAw=
github.com/docker/cli v29.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ=
github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 h1:kGSXZ2P6K0Tv/5675P4JEPZJFGHe9AecX5g2F1/+71E=
github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5/go.mod h1:RCd46PT+6sEztld6XpkCrG1xskb0u3SqxIjy4G897Ss=
github.com/livekit/protocol v1.46.0 h1:onBkn2UEIX4qboVRtbdR1KZYNUoHfxHbgg7nqJ76e5s=
github.com/livekit/protocol v1.46.0/go.mod h1:KEPIJ/ZdMFQ9tmmfv/uT9TjQEuEcZupCZBabuRGEC1k=
github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw=
github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk=
github.com/livekit/server-sdk-go/v2 v2.16.6 h1:NBKw5l1AAOHsHAZKzuzAGzILRmSm+4E+/YZ9ZiaqudI=
github.com/livekit/server-sdk-go/v2 v2.16.6/go.mod h1:1+duFCDFpAvHqZ6mHQe7IwjecLIBFv/keJcexuFhD+0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8=
github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4=
github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM=
github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0=
github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=

52
internal/ascii/ascii.go Normal file
View file

@ -0,0 +1,52 @@
// Package ascii converts raw video frames into truecolor terminal art.
//
// Each character cell renders two vertical pixels using the upper-half block
// (▀): foreground colors the top pixel, background the bottom — the same
// technique doom-ascii uses. A WxH terminal therefore displays a Wx(2H)
// pixel image.
package ascii
import (
"fmt"
"strings"
)
// FrameRGB renders a packed RGB24 frame (w*h*3 bytes, as ffmpeg's rawvideo
// rgb24 emits) sized exactly for the target cell grid: w columns, h*2 rows
// of pixels. Rows are joined with \r\n so the output is PTY-safe.
func FrameRGB(buf []byte, w, h int) string {
if len(buf) < w*h*3 || w <= 0 || h <= 0 {
return ""
}
rows := h / 2
var b strings.Builder
b.Grow(rows * w * 40)
for row := 0; row < rows; row++ {
top := row * 2
bot := top + 1
for x := 0; x < w; x++ {
ti := (top*w + x) * 3
bi := (bot*w + x) * 3
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀",
buf[ti], buf[ti+1], buf[ti+2],
buf[bi], buf[bi+1], buf[bi+2])
}
b.WriteString("\x1b[0m")
if row != rows-1 {
b.WriteString("\r\n")
}
}
return b.String()
}
// FitEven clamps a terminal geometry to an even pixel height for the
// half-block renderer and returns pixel dimensions (pw, ph) for the decoder.
func FitEven(cols, rows int) (pw, ph int) {
if cols < 8 {
cols = 8
}
if rows < 4 {
rows = 4
}
return cols, (rows - 1) * 2 // leave one status line
}

213
internal/calls/calls.go Normal file
View file

@ -0,0 +1,213 @@
// Package calls renders PairUX video calls as truecolor ASCII in the
// terminal: `ssh video-<code>@host` joins directly, `ssh video@host` prompts
// for a code. Codes are minted by PairUX — the SSH surface never creates
// calls, it only joins existing ones.
package calls
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"github.com/profullstack/agentbbs/internal/ascii"
)
// RouteCode extracts the call code from an SSH username: "video" → "",
// "video-abc123" → "abc123". Second return is false when the username is
// not a video route at all.
func RouteCode(username string) (string, bool) {
u := strings.ToLower(username)
if u == "video" {
return "", true
}
if strings.HasPrefix(u, "video-") && len(u) > len("video-") {
return u[len("video-"):], true
}
return "", false
}
// Handle runs the call UI on the session. Standalone surface: leaving the
// call ends the SSH session.
func Handle(s ssh.Session, code, identity string) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
_, _ = s.Write([]byte("video calls need a terminal (ssh -t)\r\n"))
return nil
}
w, h := ptyReq.Window.Width, ptyReq.Window.Height
if w <= 0 {
w = 80
}
if h <= 0 {
h = 24
}
m := &model{
code: code,
identity: identity,
cfg: ConfigFromEnv(),
width: w,
height: h,
}
p := tea.NewProgram(m,
tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
go func() {
for w := range winCh {
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
}
}()
_, err := p.Run()
if m.sess != nil {
m.sess.Close()
}
return err
}
type frameMsg string
type statusMsg string
type joinedMsg struct {
sess *session
err error
}
type model struct {
code string
identity string
cfg Config
input string // code entry buffer
sess *session
frame string
status string
errMsg string
width, height int
pw int // pixel width locked at join time (decoder output width)
joining bool
}
var (
vTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
vDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
vErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
)
func (m *model) Init() tea.Cmd {
if m.code != "" {
return m.join()
}
return nil
}
// join connects in the background and pumps frames/status into the program.
func (m *model) join() tea.Cmd {
m.joining = true
code := m.code
pw, ph := ascii.FitEven(m.width, m.height)
m.pw = pw // decoder output is locked to this width for the session
cfg, id := m.cfg, m.identity
return func() tea.Msg {
sess, err := join(cfg, code, id, pw, ph)
return joinedMsg{sess: sess, err: err}
}
}
// nextFrame renders one decoded frame at the locked decoder width.
func (m *model) nextFrame() tea.Cmd {
sess, pw := m.sess, m.pw
return func() tea.Msg {
buf, ok := <-sess.Frames
if !ok {
return statusMsg("stream ended")
}
ph := (len(buf) / 3) / pw
return frameMsg(ascii.FrameRGB(buf, pw, ph))
}
}
func (m *model) pump() tea.Cmd {
sess := m.sess
return tea.Batch(
m.nextFrame(),
func() tea.Msg { return statusMsg(<-sess.Status) },
)
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case joinedMsg:
m.joining = false
if msg.err != nil {
m.errMsg = msg.err.Error()
return m, nil
}
m.sess = msg.sess
return m, m.pump()
case frameMsg:
m.frame = string(msg)
return m, m.nextFrame()
case statusMsg:
m.status = string(msg)
if m.sess != nil {
return m, func() tea.Msg { return statusMsg(<-m.sess.Status) }
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
if m.sess == nil && m.code == "" && msg.String() == "q" {
break // let people type codes containing q
}
return m, tea.Quit
case "esc":
return m, tea.Quit
}
// Code entry mode.
if m.sess == nil && !m.joining && m.code == "" {
switch msg.String() {
case "enter":
if strings.TrimSpace(m.input) != "" {
m.code = strings.TrimSpace(strings.ToLower(m.input))
return m, m.join()
}
case "backspace":
if len(m.input) > 0 {
m.input = m.input[:len(m.input)-1]
}
default:
if len(msg.String()) == 1 && len(m.input) < 64 {
m.input += msg.String()
}
}
}
}
return m, nil
}
func (m *model) View() string {
switch {
case m.errMsg != "":
return lipgloss.NewStyle().Padding(1, 2).Render(
vTitle.Render("PairUX video") + "\n\n" +
vErr.Render(m.errMsg) + "\n\n" +
vDim.Render("esc to leave"))
case m.sess == nil && !m.joining:
return lipgloss.NewStyle().Padding(1, 2).Render(
vTitle.Render("PairUX video") + "\n\n" +
"Enter your call code (from pairux.com):\n\n" +
" > " + m.input + "█\n\n" +
vDim.Render("enter join · esc leave — don't have a code? create the call in PairUX first"))
case m.joining:
return lipgloss.NewStyle().Padding(1, 2).Render(
vTitle.Render("PairUX video") + "\n\n joining " + m.code + "…")
case m.frame == "":
return lipgloss.NewStyle().Padding(1, 2).Render(
vTitle.Render("PairUX video") + "\n\n " + m.status + "\n\n" +
vDim.Render("esc to leave"))
default:
return m.frame + "\r\n" + vDim.Render(" "+m.code+" · "+m.status+" · esc to leave")
}
}

223
internal/calls/livekit.go Normal file
View file

@ -0,0 +1,223 @@
// LiveKit subscriber: joins a PairUX room, takes the first remote VP8 video
// track, remuxes RTP into IVF, and has ffmpeg decode + scale it into raw
// RGB24 frames sized for the terminal grid.
package calls
import (
"fmt"
"io"
"os"
"os/exec"
"time"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media/ivfwriter"
)
// Config is the LiveKit deployment shared with PairUX.
type Config struct {
URL, Key, Secret string
}
// ConfigFromEnv reads AGENTBBS_LIVEKIT_* with LIVEKIT_* fallbacks, matching
// PairUX's env shape (LIVEKIT_API_KEY / LIVEKIT_API_SECRET).
func ConfigFromEnv() Config {
pick := func(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}
return Config{
URL: pick("AGENTBBS_LIVEKIT_URL", "LIVEKIT_URL", "NEXT_PUBLIC_LIVEKIT_URL"),
Key: pick("AGENTBBS_LIVEKIT_KEY", "LIVEKIT_API_KEY"),
Secret: pick("AGENTBBS_LIVEKIT_SECRET", "LIVEKIT_API_SECRET"),
}
}
func (c Config) ok() bool { return c.URL != "" && c.Key != "" && c.Secret != "" }
// session is one live subscription: frames arrive on Frames sized pw*ph*3.
type session struct {
Frames chan []byte
Status chan string
room *lksdk.Room
ffmpeg *exec.Cmd
ivf io.WriteCloser
done chan struct{}
}
// join connects to room `code` as a hidden-ish subscriber and starts the
// decode pipeline targeting pw x ph pixels.
func join(cfg Config, code, identity string, pw, ph int) (*session, error) {
if !cfg.ok() {
return nil, fmt.Errorf("video calls are not configured on this host (LIVEKIT_URL/API_KEY/API_SECRET)")
}
s := &session{
Frames: make(chan []byte, 2),
Status: make(chan string, 8),
done: make(chan struct{}),
}
// ffmpeg: IVF (VP8) on stdin → raw RGB24 frames on stdout.
ffin, ivfW := io.Pipe()
s.ivf = ivfW
if dump := os.Getenv("AGENTBBS_VIDEO_DEBUG"); dump != "" {
if f, err := os.Create(dump); err == nil {
s.ivf = teeWriteCloser{io.MultiWriter(ivfW, f), ivfW, f}
}
}
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error",
"-probesize", "32", "-analyzeduration", "0",
"-fflags", "nobuffer", "-flags", "low_delay",
"-f", "ivf", "-i", "pipe:0",
"-vf", fmt.Sprintf("scale=%d:%d", pw, ph),
"-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1",
)
cmd.Stdin = ffin
cmd.Stderr = os.Stderr // surfaces decode errors in the server log
out, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("ffmpeg: %w", err)
}
s.ffmpeg = cmd
go func() { // frame pump: drop frames rather than lag
size := pw * ph * 3
for {
buf := make([]byte, size)
if _, err := io.ReadFull(out, buf); err != nil {
close(s.Frames)
return
}
select {
case s.Frames <- buf:
default:
}
}
}()
gotTrack := false
cb := &lksdk.RoomCallback{
ParticipantCallback: lksdk.ParticipantCallback{
OnTrackSubscribed: func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) {
if gotTrack || track.Kind() != webrtc.RTPCodecTypeVideo {
return
}
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
s.status("skipping non-VP8 track from " + rp.Identity())
return
}
gotTrack = true
s.status("video from " + rp.Identity())
// PLI is what makes the SFU start forwarding from a
// keyframe; without it a fresh subscriber starves.
rp.WritePLI(track.SSRC())
go s.keyframeTicker(rp, track.SSRC())
go s.consume(track)
},
},
OnDisconnected: func() { s.status("disconnected") },
}
room, err := lksdk.ConnectToRoom(cfg.URL, lksdk.ConnectInfo{
APIKey: cfg.Key,
APISecret: cfg.Secret,
RoomName: code,
ParticipantIdentity: identity,
}, cb)
if err != nil {
s.Close()
return nil, fmt.Errorf("livekit: %w", err)
}
s.room = room
s.status("joined " + code + " — waiting for video…")
return s, nil
}
// keyframeTicker re-requests keyframes so late joins and packet loss recover
// quickly; a periodic full refresh is cheap at terminal resolutions.
func (s *session) keyframeTicker(rp *lksdk.RemoteParticipant, ssrc webrtc.SSRC) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for range t.C {
select {
case <-s.done:
return
default:
}
rp.WritePLI(ssrc)
}
}
// consume remuxes the track's RTP into the IVF pipe until it ends.
func (s *session) consume(track *webrtc.TrackRemote) {
w, err := ivfwriter.NewWith(s.ivf)
if err != nil {
s.status("ivf: " + err.Error())
return
}
n := 0
for {
pkt, _, err := track.ReadRTP()
if err != nil {
s.status(fmt.Sprintf("track ended after %d packets: %v", n, err))
_ = w.Close()
return
}
if err := w.WriteRTP(pkt); err != nil {
s.status("ivf write: " + err.Error())
return
}
n++
if n == 1 || n%500 == 0 {
s.status(fmt.Sprintf("receiving (%d rtp packets)", n))
}
}
}
func (s *session) status(msg string) {
select {
case s.Status <- msg:
default:
}
}
// teeWriteCloser mirrors the IVF stream to a debug file (AGENTBBS_VIDEO_DEBUG).
type teeWriteCloser struct {
io.Writer
pipe io.WriteCloser
file io.Closer
}
func (t teeWriteCloser) Close() error {
_ = t.file.Close()
return t.pipe.Close()
}
// Close tears the whole pipeline down.
func (s *session) Close() {
select {
case <-s.done:
default:
close(s.done)
}
if s.room != nil {
s.room.Disconnect()
}
if s.ivf != nil {
_ = s.ivf.Close()
}
if s.ffmpeg != nil && s.ffmpeg.Process != nil {
_ = s.ffmpeg.Process.Kill()
_ = s.ffmpeg.Wait()
}
}

177
internal/chat/chat.go Normal file
View file

@ -0,0 +1,177 @@
// Package chat is the agent@ surface: talk to the operator's AI agent (or,
// once the M2 admin console lands, the operator live).
//
// The agent backend is one configurable command, AGENTBBS_AGENT_CMD: each
// user message is piped to its stdin, stdout comes back as the reply. Point
// it at anything — `claude -p`, a logicsrc/commandboard agent, a shell
// script. Unset = a polite "leave a message" mode (messages are persisted
// either way, so the operator can read them later).
package chat
import (
"context"
"os"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/store"
)
const historyLines = 200
// Handle runs the chat UI; leaving ends the SSH session.
func Handle(s ssh.Session, st store.Store, user auth.User) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
_, _ = s.Write([]byte("agent chat needs a terminal (ssh -t)\r\n"))
return nil
}
m := &model{
st: st,
user: user,
width: ptyReq.Window.Width,
height: ptyReq.Window.Height,
}
if msgs, err := st.RecentChats(user.Name, 20); err == nil {
for _, c := range msgs {
m.lines = append(m.lines, render(c.Role, c.Text))
}
if len(msgs) > 0 {
m.lines = append(m.lines, cDim.Render("— earlier conversation —"))
}
}
p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
go func() {
for w := range winCh {
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
}
}()
_, err := p.Run()
return err
}
var (
cTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc"))
cYou = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
cAgent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c084fc"))
cDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
)
func render(role, text string) string {
if role == "user" {
return cYou.Render("you ") + text
}
return cAgent.Render("agent ") + text
}
type replyMsg struct {
text string
err error
}
type model struct {
st store.Store
user auth.User
lines []string
input string
waiting bool
width, height int
}
func (m *model) Init() tea.Cmd { return nil }
// ask pipes the message to the configured agent command.
func (m *model) ask(text string) tea.Cmd {
return func() tea.Msg {
cmdline := strings.TrimSpace(os.Getenv("AGENTBBS_AGENT_CMD"))
if cmdline == "" {
return replyMsg{text: "The operator's agent isn't wired up right now — your message is saved and a human will read it."}
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
parts := strings.Fields(cmdline)
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
cmd.Stdin = strings.NewReader(text)
out, err := cmd.Output()
if err != nil {
return replyMsg{err: err}
}
reply := strings.TrimSpace(string(out))
if reply == "" {
reply = "(no reply)"
}
return replyMsg{text: reply}
}
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case replyMsg:
m.waiting = false
if msg.err != nil {
m.lines = append(m.lines, cErr.Render("agent error: "+msg.err.Error()))
return m, nil
}
_ = m.st.AddChat(m.user.StoreID, m.user.Name, "agent", msg.text)
for _, l := range strings.Split(msg.text, "\n") {
m.lines = append(m.lines, render("agent", l))
}
if len(m.lines) > historyLines {
m.lines = m.lines[len(m.lines)-historyLines:]
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter":
text := strings.TrimSpace(m.input)
if text == "" || m.waiting {
return m, nil
}
m.input = ""
m.waiting = true
_ = m.st.AddChat(m.user.StoreID, m.user.Name, "user", text)
m.lines = append(m.lines, render("user", text))
return m, m.ask(text)
case "backspace":
if len(m.input) > 0 {
m.input = m.input[:len(m.input)-1]
}
default:
if msg.Type == tea.KeyRunes || msg.String() == " " {
m.input += string(msg.Runes)
}
}
}
return m, nil
}
func (m *model) View() string {
rows := m.height - 4
if rows < 3 {
rows = 3
}
start := 0
if len(m.lines) > rows {
start = len(m.lines) - rows
}
body := strings.Join(m.lines[start:], "\n")
prompt := "> " + m.input + "█"
if m.waiting {
prompt = cDim.Render("agent is thinking…")
}
return lipgloss.NewStyle().Padding(0, 1).Render(
cTitle.Render("agent@ — talk to profullstack") + cDim.Render(" (esc to leave)") + "\n" +
body + "\n\n" + prompt)
}

View file

@ -35,6 +35,10 @@ type Store interface {
EnsureUser(name, kind, pubkeyFP string) (User, error)
// UserByFingerprint finds an account by SSH key fingerprint.
UserByFingerprint(fp string) (User, bool, error)
// UserByName finds an account by exact username (no creation).
UserByName(name string) (User, bool, error)
// LastSeen reports the start of the user's most recent session.
LastSeen(userID int64) (time.Time, bool, error)
RecordSession(userID int64, username, remote, route string) (int64, error)
EndSession(sessionID int64) error
@ -46,9 +50,20 @@ type Store interface {
PodPaidUntil(userID int64) (time.Time, bool, error)
GrantPod(userID int64, until time.Time, paymentRef string) error
// Chat transcripts for the agent@ surface.
AddChat(userID int64, username, role, text string) error
RecentChats(username string, n int) ([]ChatMessage, error)
Close() error
}
// ChatMessage is one line of an agent@ conversation.
type ChatMessage struct {
Role string // "user" or "agent"
Text string
At time.Time
}
// ErrKeyMismatch means a username is already registered with another key.
var ErrKeyMismatch = errors.New("username registered with a different key")
@ -92,6 +107,15 @@ CREATE TABLE IF NOT EXISTS scores (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY,
user_id INTEGER,
username TEXT NOT NULL,
role TEXT NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_messages(username, id);
CREATE TABLE IF NOT EXISTS pod_subscriptions (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
paid_until TEXT NOT NULL,
@ -211,4 +235,65 @@ func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error
return err
}
func (s *sqliteStore) UserByName(name string) (User, bool, error) {
var u User
var created string
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
if err == sql.ErrNoRows {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, true, nil
}
func (s *sqliteStore) LastSeen(userID int64) (time.Time, bool, error) {
var at string
err := s.db.QueryRow(`SELECT started_at FROM sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID).Scan(&at)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
t, err := time.Parse(time.RFC3339, at)
return t, err == nil, err
}
func (s *sqliteStore) AddChat(userID int64, username, role, text string) error {
var uid any
if userID > 0 {
uid = userID
}
_, err := s.db.Exec(`INSERT INTO chat_messages (user_id, username, role, text) VALUES (?,?,?,?)`,
uid, username, role, text)
return err
}
func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) {
rows, err := s.db.Query(`
SELECT role, text, created_at FROM (
SELECT id, role, text, created_at FROM chat_messages
WHERE username = ? ORDER BY id DESC LIMIT ?
) ORDER BY id ASC`, username, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatMessage
for rows.Next() {
var m ChatMessage
var at string
if err := rows.Scan(&m.Role, &m.Text, &at); err != nil {
return nil, err
}
m.At, _ = time.Parse(time.RFC3339, at)
out = append(out, m)
}
return out, rows.Err()
}
func (s *sqliteStore) Close() error { return s.db.Close() }

BIN
lkpublish Executable file

Binary file not shown.

2
mise.toml Normal file
View file

@ -0,0 +1,2 @@
[tools]
go = "1.26"