Skip to content

Answer the user's own inbound FaceTime Audio call - #5387

Merged
atomantic merged 1 commit into
mainfrom
next/issue-5310
Aug 29, 2026
Merged

Answer the user's own inbound FaceTime Audio call#5387
atomantic merged 1 commit into
mainfrom
next/issue-5310

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

  • Adds 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.
  • Fail-closed at the helper boundary: 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.
  • Reading the helper (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.
  • 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 2-second tick) instead of silently dropping it.
  • Quiet hours only soften the greeting's wording; they never decide whether to answer.
  • When the Persistent Mind is running at answer time, the call carries its persona/context and the transcript is handed back to it as a message on hangup, mirroring how an outbound mind-placed call already works; when it isn't running, the call runs the plain voice persona like the widget.
  • New voice:call:hangup socket event, 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 — usable from any tab, not just the call host.
  • voice:call:state now also broadcasts to every connected tab via a new callStateEvents emitter (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.
  • Settings → Voice → FaceTime Audio gets an Automatically answer incoming calls toggle with the plain-language rule.

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 injected probe/answer stub 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 emits voice:call:hangup.
  • Full suites green: server && npx vitest run (35549 passed), client && npx vitest run (10262 passed), npm run lint, npm run build.
  • Regenerated server/lib/apiRouteCatalog.generated.json and server/lib/socketEventCatalog.generated.json.

Closes #5310
Part of #5306

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.
@atomantic
atomantic merged commit fdce140 into main Aug 29, 2026
7 checks passed
@atomantic
atomantic deleted the next/issue-5310 branch August 29, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[repo-study-facetime-bridge-inbound-answer] FaceTime Audio phase 4: answer the user's own call and run a Mind conversation over it

1 participant