Answer the user's own inbound FaceTime Audio call - #5387
Merged
Conversation
Phase 4 of the FaceTime Audio bridge (#5306): a call-host tab attached plus the new facetime.autoAnswer setting (default off) lets PortOS pick up a call from the user's own configured identity and run it through the existing voice pipeline, exactly like an outbound call. Fail-closed by construction, not by convention: - callSession.js gains an incoming watcher armed only while a call-host tab is attached; reading the helper (probe) is safe and cheap, but the press (answer) additionally checks the host is attached *at press time*, since answering a call nobody can hear is worse than missing it. The helper boundary reports nothing distinguishing "no call" from "an unauthorized caller" (fail-closed at facetimeBridge.answer), so this module never learns an unauthorized caller exists and never logs one. - An authorized call that rings with no host attached, or one the helper fails to press, raises a `medium` agent_warning notification (deduped once per ring, not once per 2s tick) so the miss is visible instead of silently dropped. - Quiet hours only soften the greeting's wording; they never decide whether to answer — the user placed the call. - When the Persistent Mind is running at answer time, the call carries its persona/context and the transcript goes back to it as a message on hangup (same continuity path outbound mind-placed calls use); when it isn't, the call runs the plain voice persona like the widget. Also adds voice:call:hangup (routed through endCall, not a raw facetimeBridge.hangup(), so the session's journal write and mind handoff run the same as any other end-of-call) and broadcasts voice:call:state to every connected tab via a new callStateEvents emitter, not just the call-host socket — the Mind tab's active-call chip needs call state without being the tab carrying the audio.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
facetime.autoAnswer(default off). With it on and a call-host tab attached,callSession.js's new incoming watcher answers a call from the user's own configured identity and runs it through the existing voice pipeline (whisper STT → voice LLM/persona → Kokoro/Piper TTS), exactly like an outbound call.facetimeBridge.answer()reports nothing distinguishing "no call" from "an unauthorized caller," so this module never learns an unauthorized caller exists and never logs one — a call from any other handle is simply left ringing.probe) each tick is safe and runs while the watcher is armed; the press (answer) is additionally gated on the call-host tab being attached at press time — answering a call nobody can hear is worse than missing it.mediumagent_warningnotification (deduped once per ring, not once per 2-second tick) instead of silently dropping it.voice:call:hangupsocket event, routed throughendCall()(not a rawfacetimeBridge.hangup()) so the session's journal write and mind handoff run the same as any other end-of-call — usable from any tab, not just the call host.voice:call:statenow also broadcasts to every connected tab via a newcallStateEventsemitter (the call-host socket still gets its own direct emit, which drives opening-line delivery). The Mind tab shows an active-call chip with a Hang up button driven by this broadcast.Test plan
cd server && npx vitest run services/voice/callSession.test.js services/voice/facetimeBridge.test.js sockets/voice.test.js routes/voice.test.js services/persistentMindCallCapability.test.js— watcher tests use an injectedprobe/answerstub and fake timers (armed-only-while-attached, dedup, mind handoff, quiet-hours greeting, no-log-on-unauthorized).cd client && npx vitest run src/components/cos/tabs/MindTab.test.jsx— active-call chip appears from a broadcast even when this tab isn't the call host, and hang up emitsvoice:call:hangup.server && npx vitest run(35549 passed),client && npx vitest run(10262 passed),npm run lint,npm run build.server/lib/apiRouteCatalog.generated.jsonandserver/lib/socketEventCatalog.generated.json.Closes #5310
Part of #5306