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

@ -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>")