feat(voice,host): the avatar's frames on the voice socket, its grant and status in process, and voice.setVideo
Plan item 8. host: avatar frames and setVideo. Stacks on #2472 (contracts).
What
live-session-source.ts- The hosted source's
LiveSessionOpenedgainsstartAvatar()andstopAvatar(reason). They sendavatar.startandavatar.stop {reason}on the same socket, held through a reattach gap like any send. - It also gains
onAvatarStartedandonAvatarUnavailable.withoutServiceFramestakesavatar.startedandavatar.unavailableoff the arrivals the same way it takes theplan.*frames. - An
avatar.startedthat its schema refuses (for example aws:room) is still taken off the socket, since it may carry a token. It is told asavatar.unavailable {error}, so a start is never left waiting.
- The hosted source's
LiveSessionHolderstartAvatar(): booleanasks only while a call stands and no avatar is already asked for or up, because each start mints a Keyframe session the plan's concurrency pays for. After an end, it may ask again.stopAvatar(reason): booleanwithdraws a handed-on grant.- The call's end moves the avatar to
ENDED {call_ended}and withdraws the grant. It sends no stop, because the service closes the avatar with the call. - Two in-process observables:
avatarStates: Stream<LiveAvatarState>replays the current state, then each change:OFF | STARTING | GRANTED | ENDED {by: stopped|unavailable|call_ended, reason}.avatarGrants: Stream<AvatarGrant | undefined>replays nothing, so no grant is kept for reuse. A grant reaches only the subscribers standing when it arrives.undefinedmeans the avatar it joined is down.
- A grant that lands after a stop, a refusal, or the call's end is dropped.
voice.setVideo {on}(@sidecar/gatewayandcompose-live.ts):oncallsstartAvatar(), andoffcallsstopAvatar(user). The answer is{requested: boolean}, and no Gateway answer or event carries the grant.LiveDependenciesnarrowssettingsandaccountto the fields compose-live reads, so the test can stand it up. Compose-host's call site is unchanged.HostedLiveSessionSourceis exported from@sidecar/voicefor that test.
Consumer
#11 (wire-up) consumes avatarGrants (to callWindow.showAvatarSession) and avatarStates (to state.voice.avatar), and adds the VOICE_SET_VIDEO act and the auto-start after session.created.
The host status is its own vocabulary, because packages cannot import #shared/messages/avatar. #11 maps it like this:
STARTINGandGRANTED→STARTING.LIVEis the call window's own report.stopped/user→USERunavailable/no_capacity→NO_CAPACITYunavailable/not_configured→UNAVAILABLEunavailable/error→ERRORcall_ended→CALL_ENDED
When the call window's session closes or fails, #11 calls holder.stopAvatar(server_closed | error) in process. That ends the avatar here and tells the service, so "Restart video" (setVideo on) can draw a fresh grant.
Web side (#7): the sessions route still closes a desktop socket that sends any other frame. That's harmless now, because nothing calls voice.setVideo until #11.
Tests
packages/host/src/compose-live.test.ts runs the real hosted source over the existing scripted socket (@sidecar/voice/testing), the real holder, and compose-live's method table:
- on → one
avatar.start, and a second press while starting sends nothing; - the grant reaches
avatarGrants; off →avatar.stop {user}, the grant is withdrawn, and the states run in order; - no Gateway answer or event carries the token, checked against the serialized bytes;
- with no call, nothing is asked; a non-boolean is an invalid-params refusal;
- after an
avatar.unavailable, a late grant is dropped, and the next start draws its own grant; - a
ws:grant is read as unavailableerror; - the call's end withdraws the grant and sends no stop.
I mutation-checked them: removing the call-end withdrawal fails a test.
./scripts/check.sh exits 0. This PR doesn't touch the Mac or the UI (packages only), so it needs no verify.sh evidence. CI builds nothing for the Mac.
🤖 Generated with Claude Code
Summary by cubic
Adds the avatar lifecycle to the hosted voice session and exposes it through a new voice.setVideo Gateway method, keeping every grant in process only.
startAvatar()andstopAvatar(reason)sendavatar.startandavatar.stopon the voice socket; each start carries its own request id, and only the latest start's answer is heard.avatar.startedandavatar.unavailableare taken off arrivals like theplan.*frames; an answer its schema refuses still ends the start it names as unavailable (error).voice.setVideo {on}answers only{requested}; no Gateway answer or event carries the grant token.avatarStatesreplays the current state (OFF | STARTING | GRANTED | ENDED);avatarGrantsreplays nothing, so a single-use credential is never kept for a later start.- A grant landing after a stop, refusal, or call end is dropped; the call's end withdraws the grant without sending its own stop.
- A new call starts with the avatar off, not at the previous call's end state.