Skip to content

Live Voice: codex realtime voice over WebRTC - #2657

Open
colonelpanic8 wants to merge 32 commits into
getpaseo:mainfrom
colonelpanic8:realtime-voice-actions
Open

Live Voice: codex realtime voice over WebRTC#2657
colonelpanic8 wants to merge 32 commits into
getpaseo:mainfrom
colonelpanic8:realtime-voice-actions

Conversation

@colonelpanic8

@colonelpanic8 colonelpanic8 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Live Voice, an app-global realtime speech-to-speech mode that lets the user talk to Paseo through Codex's experimental realtime v3 API. Browser/Electron and native clients establish a WebRTC peer directly with OpenAI; the daemon carries only SDP, control messages, finalized transcripts, and routed Paseo tool calls.

Call architecture and lifecycle

  • Each call runs in a hidden, internal Codex host session in the daemon user's home directory. It is not attached to a project, workspace, or visible agent session, and its realtime events never enter the durable agent timeline.
  • The Codex app-server launches with --enable realtime_conversation behind a Codex 0.145.0 version floor. The start response carries the answer SDP, avoiding a push/response race; voice.live.update sends owner-only, monotonically sequenced started/transcript/error/closed events.
  • Ownership is the exact source socket, with at most one call per socket. The app pins that host connection so adaptive direct/relay selection cannot replace it mid-call. Client stop, socket loss/replacement, Codex error/close/exit, hidden-host teardown, and startup failure all converge on idempotent cleanup of the WebRTC session, microphone lease, route registration, connection pin, and hidden host.
  • Media stays client ↔ OpenAI. Paseo and the app never receive or store an OpenAI API key for this path; Codex uses its existing ChatGPT-subscription authentication.

Acting on Paseo across hosts

  • The realtime model receives a Paseo-specific spoken-output/delegation prompt plus bounded developer-role snapshots of the source daemon's agent sessions and workspaces. includeStartupContext: false prevents Codex startup context from competing with it; context-build failure falls back to Codex defaults rather than failing the call.
  • The hidden host is restricted to two routing tools: list the hosts visible to the owning app, then run one ordinary top-level Paseo tool on a selected host. The request travels through the exact owning app socket, which authorizes the active liveSessionId, selects and pins the saved target host, and executes against that daemon without a caller agent id or inherited workspace authority.
  • Only sanitized host labels/status/version and JSON-safe tool arguments/results cross the route. Credentials, endpoints, relay keys, and OpenAI credentials do not. The restricted hidden-host catalog also prevents recursive acquisition of the Live Voice routing tools.
  • Cross-host routing is gated by the client's live_voice_cross_host_router capability and the target daemon's liveVoiceToolExecution feature. Older clients keep local-only Live Voice behavior. If Paseo MCP injection is disabled, the prompt says the model can describe state but cannot act.

App and mobile experience

  • Live Voice launches from a compact waveform button in the sidebar footer. The menu selects an eligible host and offers foreground mode everywhere plus background mode on native. Idle Live Voice occupies no additional layout space.
  • An active call follows navigation in exactly one surface: a sidebar card when that slot is visible, otherwise a root-level docked strip that does not cover the composer. Both expose status, mute, stop/dismiss, autoplay recovery, and a collapsed transcript preview with a bounded, bottom-pinned expanded history.
  • Cmd+Shift+M / Ctrl+Shift+M toggles Live Voice mute globally (while preserving terminal key ownership and user shortcut overrides). Availability errors stay concise in the launcher; per-host connection/version/capability details live under Settings → Diagnostics. Host status subscriptions now recompute per host so unrelated connection churn cannot leave stale availability.
  • A single app-global microphone lease makes provider-agnostic voice mode, dictation, and Live Voice mutually exclusive. Acquisition is atomic and refuses instead of interrupting the current owner, including start/stop races while the microphone is already open.
  • Web/Electron uses browser WebRTC with ICE completion and autoplay-policy recovery. iOS/Android uses react-native-webrtc; foreground mode remains app-bound, while background mode keeps calls alive across Home/screen lock through an iOS playAndRecord/voiceChat audio session or an Android microphone/media-playback foreground service with an ongoing notification.
  • Native bindings load lazily so an OTA bundle can still start on an older binary. Android background startup checks activity lifecycle rather than modal window focus, allowing the background action selected from the native menu to start correctly. Required native permissions/config and localized UI strings for all existing app locales are included.

Protocol and validation

  • Adds dotted voice.live.start/stop, voice.live.route, and voice.live.tool.execute request/response RPCs plus the owner-targeted voice.live.update push. New server/client capabilities remain optional, and error/cause codes stay open strings for version skew.
  • Patches the pinned zod-aot generator so discriminated unions preserve non-string literal tags (used by boolean ok results) and recursive lazy JSON leaves fall back to runtime Zod instead of recursing into the WebSocket envelope. Regression coverage and protocol-validation documentation are included.

Still out of scope

Proactive appendSpeech notifications, transcript deltas, a voice picker, and reconnect/resume after a real transport loss.

Verification

  • Focused automated coverage spans protocol compatibility/AOT validation, client RPC routing, daemon coordinator teardown and SDP ordering, hidden-host context, route authorization/brokering/tool execution, Web and native session cleanup, mobile background lifetime, microphone leasing, availability/host status, and the app runtime's stale-update and negotiation-race handling.
  • Real-provider smoke (packages/server/scripts/live-voice-smoke.ts, reported passing locally): isolated in-process daemon with MCP enabled + real Codex + real Chrome; applies the relayed answer, reaches WebRTC connected, observes session.started, verifies the Paseo prompt/tool injection/snapshot metadata, receives owner-targeted monotonic updates, and stops cleanly.
  • Independent adversarial cross-model review was completed and its findings were covered by focused tests.
  • npm run typecheck, npm run lint, and npm run format were reported clean; full suites remain delegated to CI per repository policy.

Manual physical-device coverage still required:

  • iOS foreground/background call, Home + screen lock for >60s, bidirectional speech after resume, clean teardown
  • Android foreground/background call, persistent notification, Home + screen lock for >60s, cross-host action while backgrounded, clean teardown

Comment thread packages/app/src/live-voice/live-voice-runtime.ts
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds Live Voice across the app, protocol, client, and daemon.

  • Introduces web and native WebRTC sessions, global microphone ownership, background-call support, and app-wide call controls.
  • Adds daemon coordination, Codex realtime integration, cross-host tool routing, capability negotiation, and protocol validation.
  • Adds lifecycle, routing, context-building, platform, and end-to-end smoke coverage.

Confidence Score: 4/5

The PR is not yet safe to merge because host removal can still strand Live Voice resources and reconnect handling can erase terminal errors.

Removing the owning session leaves the connection watcher with a null client and returns without invoking runtime teardown, while connection loss in the error phase still replaces the original terminal failure with owner_disconnected. The previously reported pending-start and ordinary active-call disconnect paths are protected by generation checks and deferred resource release.

Files Needing Attention: packages/app/src/contexts/live-voice-context.tsx, packages/app/src/live-voice/live-voice-runtime.ts

Important Files Changed

Filename Overview
packages/app/src/live-voice/live-voice-runtime.ts Implements the app-global Live Voice lifecycle, WebRTC startup, microphone lease ownership, teardown, and connection-loss handling.
packages/app/src/contexts/live-voice-context.tsx Installs the singleton runtime and watches host client status and replacement events to coordinate teardown.
packages/server/src/server/live-voice/live-voice-coordinator.ts Coordinates daemon-side call ownership, startup ordering, updates, and terminal cleanup.
packages/server/src/server/agent/providers/codex-app-server-agent.ts Adds Codex realtime-conversation capability gating and app-server integration.
packages/protocol/src/messages.ts Defines Live Voice requests, responses, updates, routing messages, and capability fields.
packages/app/src/live-voice/live-voice-session.web.ts Implements browser and Electron WebRTC negotiation, media handling, and data-channel events.
packages/app/src/live-voice/live-voice-session.native.ts Implements native WebRTC sessions and background-call lifetime integration.
packages/app/src/runtime/host-runtime.ts Adds active-connection pinning needed to preserve exact socket ownership during calls.

Sequence Diagram

sequenceDiagram
  participant U as User
  participant App as Paseo app
  participant D as Owning daemon
  participant C as Codex realtime
  participant O as OpenAI
  U->>App: Start Live Voice
  App->>App: Acquire microphone lease and pin connection
  App->>D: voice.live.start with WebRTC offer
  D->>C: Start realtime conversation
  C->>O: Establish realtime session
  O-->>C: Answer SDP
  C-->>D: Answer SDP
  D-->>App: Start response
  App->>O: WebRTC microphone audio
  O-->>App: Remote speech audio
  D-->>App: Finalized transcript updates
  U->>App: Stop call
  App->>D: voice.live.stop
  App->>App: Close peer and release lease/pin
Loading

Reviews (8): Last reviewed commit: "feat(app): add Live Voice foreground deb..." | Re-trigger Greptile

colonelpanic8 added a commit to colonelpanic8/paseo that referenced this pull request Jul 30, 2026
Phase 1 promised immediate teardown on Paseo disconnect and only delivered the
daemon half. The client subscribed to voice.live.update on whichever client
existed at start and never observed connection state, so the daemon's
owner_disconnected update could not reach the app over the socket that had just
died: the runtime stayed active, the peer connection stayed open, and the
app-global microphone lease stayed held, blocking dictation and voice mode until
a reload.

The runtime gains handleConnectionLost(serverId), which invalidates any in-flight
start, tears the local half down, and reports the same owner_disconnected cause
the daemon uses so the UI has one story. The provider drives it for both losses:
a socket reporting anything other than connected, and the host swapping in a new
client on reconnect (healthy client, dead call).

Found by review on getpaseo#2657.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@colonelpanic8

Copy link
Copy Markdown
Contributor Author

Good catch on the connection-loss finding — confirmed and fixed in 6fdc5c2.

It was the client half of a contract phase 1 only half-delivered: the daemon tears a call down immediately on owner disconnect, but the app subscribed to voice.live.update on whichever DaemonClient existed at start and never observed connection state. So the owner_disconnected update could never arrive over the socket that had just died, leaving the runtime active, the peer connection open, and the app-global microphone lease held — which also blocks dictation and the existing voice mode until a reload.

The runtime now exposes handleConnectionLost(serverId): it invalidates any in-flight start (so a late-resolving negotiation can't revive the call), tears the local half down, and reports the same owner_disconnected cause the daemon uses so the UI has one story. The provider drives it for both distinct losses — a socket reporting anything other than connected, and the host swapping in a new client on reconnect (the new client is healthy, but the call died with the old one).

Covered by three new runtime tests: teardown frees the lease and sends no stop RPC, a loss for a different host is ignored, and a start that resolves after its connection was lost does not revive the call.

Note for maintainers: the CI, Nix, and Docker workflows are sitting at action_required since this is a fork PR — they need an approval to run.

Comment thread packages/app/src/live-voice/live-voice-runtime.ts Outdated
colonelpanic8 and others added 12 commits July 30, 2026 15:46
New dotted-namespace RPC surface for Live Voice (codex realtime voice over
WebRTC): voice.live.start/stop request-response pairs and the owner-targeted
voice.live.update push with a discriminated event union (started, transcript,
error, closed). Error/cause codes stay plain strings on the wire so new codes
never break old clients. Adds the COMPAT-tagged server_info.features.liveVoice
capability flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex provider: spawn app-server with --enable realtime_conversation behind a
0.145.0 version floor, expose a per-session supportsLiveVoice capability,
add thread/realtime/start|stop calls plus tolerant notification parsing routed
to a dedicated realtime subscription (deliberately not AgentStreamEvent — call
state is ephemeral, not timeline history), and give the transport an onClose
hook so a dead codex process always produces a terminal event.

New daemon-global LiveVoiceCoordinator owns one call per agent with atomic
acquire (second owner gets busy), a starting/active/stopping/closed state
machine, the answer SDP returned in the start response regardless of
notification ordering, and exactly-once teardown from every terminal cause:
client stop, owner socket detach (immediate, not the 90s retention), codex
error/closed, transport death, and agent close/archive via the new
AgentManager.onAgentClosing hook (which fires while the provider session is
still alive, so codex is told to end the upstream realtime session).

voice.live.* RPCs dispatch in session.ts with responses and updates emitted
only to the owning client. Includes an end-to-end smoke script that drives a
real Chrome offer through an in-process daemon against live codex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
daemon-client grows startLiveVoice/stopLiveVoice (45s start timeout for the
SDP round trip; rejections rethrown as LiveVoiceStartRejectedError with a
stable errorCode). The web session module owns the full WebRTC sequence —
mic, oai-events data channel before createOffer, complete ICE gathering,
answer application, autoplay-policy recovery — with one idempotent cleanup on
every exit path; native gets an unsupported stub via Metro platform files.

A non-React runtime state machine drives the call, drops updates from stale
liveSessionIds, and keeps finalized transcripts. A new app-global microphone
lease makes voice mode, dictation, and live voice mutually exclusive
(refuse, never interrupt), and stays held through a stop() that lands
mid-negotiation so the still-open mic can't be double-acquired. Composer
gains a Live Voice button and status panel, gated on web + the daemon
liveVoice feature + the agent's supportsLiveVoice capability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n it

The realtime model is a conversational front end: everything it delegates
becomes an ordinary turn on the attached agent session, and that session
already launches with Paseo's own MCP server injected. So acting on Paseo
needs no new tools (codex cannot give the realtime model client-defined tools
anyway) — it needs the model to know Paseo exists and what is running.

Sets three previously-unused start params: prompt (replaces the voice model's
entire system prompt with one describing Paseo, delegation, and spoken-output
discipline), initialItems (a developer-role snapshot of the attached session,
other sessions, and workspaces), and includeStartupContext: false so codex's
own context does not compete. Sections are dropped whole rather than truncated
mid-list, well inside codex's 128-item / 8,192-token limits, since a
half-listed set reads as complete and invites confident wrong answers.

The prompt's claim that Paseo itself is actionable is conditional on
AgentManager.hasPaseoMcpInjection(): with mcpInjectIntoAgents off the model is
told it cannot manage workspaces or sessions, so it never promises work the
session cannot do. A context build failure degrades to codex's default context
instead of costing the user their call.

The smoke script now runs with MCP enabled (production default) and asserts the
echoed session instructions are the Paseo prompt, that tools were injected, and
that the daemon seeded the snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 1 promised immediate teardown on Paseo disconnect and only delivered the
daemon half. The client subscribed to voice.live.update on whichever client
existed at start and never observed connection state, so the daemon's
owner_disconnected update could not reach the app over the socket that had just
died: the runtime stayed active, the peer connection stayed open, and the
app-global microphone lease stayed held, blocking dictation and voice mode until
a reload.

The runtime gains handleConnectionLost(serverId), which invalidates any in-flight
start, tears the local half down, and reports the same owner_disconnected cause
the daemon uses so the UI has one story. The provider drives it for both losses:
a socket reporting anything other than connected, and the host swapping in a new
client on reconnect (healthy client, dead call).

Found by review on getpaseo#2657.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tors

zod-aot's discriminated-union emitter wrote the mapping key into the
generated `case` label, which stringifies non-string discriminators (and
any literal whose runtime value differs from its key) into something the
validator can never match. Patch the emitter to resolve each option's
literal value instead, alongside the existing runtime-import and output
patches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…osts

A Live Voice call was bound to the agent session whose codex thread hosted
the realtime handshake, so talking to Paseo meant first opening some agent
and the model could only act through that one session. Nothing about a
voice call is workspace-shaped: the prompt already described every agent
and workspace on the daemon.

The daemon now spawns its own hidden internal codex session per call in the
home directory and hosts the realtime session there, so `agentId` leaves
`voice.live.*` entirely and a call is identified by `liveSessionId` alone.
Exclusivity moves to one call per owning socket, the mid-turn `agent_busy`
and `agent_not_found` rejections disappear with the attached agent, and a
dying host session closes its call as `host_session_closed`.

With no attached session to delegate to, the voice model instead routes
work: new `voice.live.route.*` and tool-execution messages let it prompt
sessions, open workspaces, and run Paseo tools on any saved host, brokered
by the daemon and executed by the client that owns that connection. The
client pins the exact daemon connection a call negotiated over so a
reconnect cannot swap its transport mid-call.

Protocol, client, server, and app land together: `voice.live.*` has not
shipped, so the field is removed outright rather than deprecated, and the
change is only valid once every layer agrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call outlives the screen it started on, so its UI cannot live in one
agent's composer: the old panel rendered only for the agent that owned the
call, and its transcript was an unbounded column that walked off screen on
a long conversation.

A call now has two homes and exactly one is active. When a visible sidebar
offers a slot, a Discord-style card sits above the sidebar footer; when it
does not — compact layouts inside a conversation, focus mode, a collapsed
sidebar — the call falls back to a slim bar docked into the root layout's
flow, so it never occludes the composer. Placement is a registration the
sidebar slot claims while it is on screen rather than re-derived layout
math, and it is claimed whether or not a call exists so ownership cannot
flap mid-call.

Both surfaces are thin readers over the app-global runtime and share their
controls, status, and transcript. The transcript is collapsed to a preview
by default and expands in place to a bounded, bottom-pinned scroll that
follows the newest line unless the reader scrolls back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Muting is the reflex action mid-call — someone walks into the room — and
reaching for a button in whichever surface currently owns the call is too
slow. Ending the call deliberately keeps no keybind: it tears down the
hidden host session and loses the realtime model's conversational memory,
where mute is a local, instant, reversible track flip.

The chord deliberately survives an editable focus scope, unlike voice
mode's Space-to-mute, because you reach for it mid-keystroke; the terminal
keeps first refusal on its own keys. The handler reports unhandled while no
call is active so the chord stays inert and does not swallow the keystroke.

Going through the declarative binding table means it shows up in the
shortcuts dialog and honors user overrides like any other binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backgrounding the app killed the call: iOS suspends the audio session and
Android reclaims the microphone, so walking away from the phone — the case
hands-free voice exists for — ended the conversation.

A native module holds the call alive on both platforms: an audio background
mode and active AVAudioSession on iOS, a media-playback foreground service
with a microphone type on Android. The native session begins the background
call when the transport comes up and ends it on teardown, so the lifetime is
tied to the call rather than to app state, and a platform that refuses the
handoff surfaces `background_unavailable` instead of failing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where the realtime session lives, which socket owns a call, why media never
carries an OpenAI key through the app, and the hop-by-hop route a tool call
takes to reach another host are all facts you cannot recover from any one
file in the implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@colonelpanic8
colonelpanic8 force-pushed the realtime-voice-actions branch from 6fdc5c2 to 00183bd Compare July 30, 2026 22:48
Comment thread packages/app/src/contexts/live-voice-context.tsx Outdated
Comment thread packages/app/src/contexts/live-voice-context.tsx
colonelpanic8 and others added 3 commits July 30, 2026 20:45
…ggregate version

useHostRuntimeConnectionStatuses re-derived the whole status map from a
version counter, so unrelated host churn could produce stale snapshots.
Key it off each host's own connectionStatus string instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The always-visible launcher docked a full status bar (or sidebar card)
at idle, with a raw debug dump of every host's connection state
whenever live voice was unavailable — on mobile it ate the lower
screen. Idle surfaces now render nothing; launching moves to a
waveform icon button in the sidebar footer with an anchored start
menu, and the per-host debug detail moves to Settings > Diagnostics
as structured rows instead of a mono text blob.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review. (144 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@colonelpanic8 colonelpanic8 changed the title Live Voice: codex realtime voice over WebRTC (phase 1) Live Voice: codex realtime voice over WebRTC Jul 31, 2026
colonelpanic8 added a commit to colonelpanic8/paseo that referenced this pull request Jul 31, 2026
Phase 1 promised immediate teardown on Paseo disconnect and only delivered the
daemon half. The client subscribed to voice.live.update on whichever client
existed at start and never observed connection state, so the daemon's
owner_disconnected update could not reach the app over the socket that had just
died: the runtime stayed active, the peer connection stayed open, and the
app-global microphone lease stayed held, blocking dictation and voice mode until
a reload.

The runtime gains handleConnectionLost(serverId), which invalidates any in-flight
start, tears the local half down, and reports the same owner_disconnected cause
the daemon uses so the UI has one story. The provider drives it for both losses:
a socket reporting anything other than connected, and the host swapping in a new
client on reconnect (healthy client, dead call).

Found by review on getpaseo#2657.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
colonelpanic8 and others added 13 commits July 31, 2026 12:28
The realtime seam could only start and stop a call, so nothing that
happened while the user was not speaking could ever reach the voice
model. Add `realtimeAppendText` to the seam, implement it on codex via
`thread/realtime/appendText`, and expose it as `LiveVoiceCoordinator.say`.

Verified against a real codex realtime session: appending a developer
item makes the model speak it unprompted, in its own words, without an
explicit response trigger.

`say` is ownership-checked rather than id-only: a client may put words in
the mouth of its own call and no other, so the requesting socket must be
the exact socket that owns the call. A provider failure comes back as a
structured result instead of throwing, because callers deliver
notifications and must not fail on a call that has since gone away.

This is the delivery primitive on its own. The routed work-notification
path that uses it lands separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Live Voice call gave no audible sign of starting or ending, which
matters most on the surface where the screen isn't in view.

Two synthesized cues, mirrored so they're distinguishable without
looking: a rising two-note figure on connect, the same notes falling on
disconnect. Both are generated from one tone spec, so web (Web Audio
buffer) and native (WAV in the cache, played by expo-audio) sound
identical. No third-party audio is added.

Firing is edge-triggered off a single latch rather than derived from the
phase, so snapshot churn (mute, transcripts, autoplay blocking) is
silent, a start that never connected plays nothing, and stop()'s
stopping -> idle pass chimes once.

Native deliberately never calls setAudioModeAsync and never touches the
shared two-way audio engine: WebRTC owns the session and the mic lease
for the duration of a call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`isLiveVoiceBackgroundSessionSupported` was a hardcoded `true`, so an OTA
bundle newer than the installed binary reported background mode as available
and then threw from `requireNativeModule` when the call started. Resolve the
module optionally and derive the flag from whether it is actually there.

Also declare `androidx.lifecycle:lifecycle-runtime` explicitly: the module's
Kotlin references `LifecycleOwner` and `Lifecycle` but only picked them up
transitively.
A backgrounded call's foreground-service notification was static text. It is
the call's only surface once Paseo leaves the screen, so muting or hanging up
meant reopening the app — which is the thing backgrounding was supposed to
avoid.

The notification now carries Mute/Unmute and End call, reflects mute state in
its title and text, and runs a chronometer for call duration. The service
still owns no call state: a button press comes back through the Expo module as
an event and the app runtime acts on it, so the runtime stays the only writer.
State flows the other way through a new `update` function, pushed edge-
triggered off the runtime snapshot — the runtime republishes for every
transcript line, and rebuilding the notification on each would be dozens of
posts a minute.

Only `background` calls get this. A `foreground` call ends when the app leaves
the screen, so a persistent control would outlive what it controls.

iOS is unchanged; its module manages the audio session and has no notification
to update, so `update` resolves silently there.
A routed Live Voice tool call has no caller agent id, deliberately, so it
cannot claim an agent's workspace authority. Dropping the caller agent id
also drops the agent-to-agent defaults that come with it, including
background execution — so `create_agent` and `send_agent_prompt` reverted
to blocking for the one caller that cannot survive a blocking call.

When that happened the tool waited, `onBackgroundAgentStarted` never
fired, no watch was ever registered, and the completion report the caller
was promised was silently never sent. The voice model got dead air and
the user got nothing, with no way for either to notice.

The prompt already asked the model to pass `background: true`, but a
guarantee that depends on the model remembering an optional argument is
not a guarantee. Add `defaultAgentWorkToBackground` to the tool runtime
context and set it whenever a routed call asked to be told the outcome.

It flips the schema default rather than injecting the argument in the
executor: schemas are built per runtime context, so the advertised
default and the field's description stay the same fact, and tools keep
owning the knowledge of which of them start work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Live Voice call only heard about work it started itself. An agent the
user launched from the app an hour ago could finish, fail, or block on a
permission request mid-call and say nothing, which is the case most worth
hearing about when you are away from the keyboard.

Add an ambient watch: with Settings -> General -> Live voice -> Report
agent activity on, the app arms every connected host that advertises
`liveVoiceAmbientAgentReports`, and those hosts report every agent that
finishes a turn, errors, or asks for permission.

The daemon-side watch is new code rather than a reuse of
`watchAgentFinish`, which watches one known agent and fires once. Here the
agent set is open and each agent keeps having turns for as long as the
call lasts, so it is one global subscription — which also picks up agents
created mid-call, and never sees internal agents, so the hidden host
cannot report on itself.

Correlation differs from a routed report, because nobody asked for these:

  - By host, not requestId. There is no routed call to match, so an
    unsolicited report resolves through the host the app armed. A host
    reporting work the app never asked it to watch resolves to nothing.
    The registration lasts the whole call instead of retiring after one
    report.
  - The model may stay silent. A routed report is an answer the user is
    owed and its note says to speak. An unsolicited one says to use
    judgement, and that saying nothing is a valid outcome.

No burst coalescing, recency filter, or event allowlist in code. What is
worth interrupting for depends on what the user is doing, so the setting
carries free text that goes into the prompt verbatim and outranks the
model's own judgement.

Teardown is call-scoped. The runtime does not await it, so a stop and the
next start overlap; disabling every host in sight let an old call's
teardown land after the new call's setup and silence it.

Also name the read tools in the prompt. Nothing told the model that
`get_agent_activity`, `get_agent_status`, or `list_pending_permissions`
existed, and "never poll for status" read as a ban on reads — so the
cheapest way to answer "what did it say?" was to prompt the agent, which
costs it a full turn and writes the question into its own history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CATEGORY_CALL was already set, but it is a ranking and DND hint — it does
not make the shade, the lock screen, or a paired watch treat the
notification as a call. CallStyle is what does, and it bridges to Wear with
call affordances rather than as a generic ongoing notification.

CallStyle owns the hang-up button, so the API 31+ path passes the End call
intent to `forOngoingCall` and adds only Mute alongside it. Below 31 the
notification keeps both explicit actions. minSdk is 29, so that fallback
covers 29 and 30 and nothing else.

The channel moves to IMPORTANCE_DEFAULT, which is what earns CallStyle its
full treatment. Two consequences worth stating:

  - Importance cannot be raised on an existing channel — the user owns it
    once it is created — so this takes a new channel id and deletes the old
    one rather than leaving a dead entry in settings.
  - DEFAULT alerts by default, and the app already plays its own connect
    cue. The channel is explicitly silenced so the two do not stack.

Verified with `:paseo-background-call:compileDebugKotlin` and `lintDebug`
against a Nix-composed SDK (platform 36, JDK 21). Lint reports no NewApi
finding, so the version guard reads correctly. How CallStyle actually
renders, and whether it bridges to a watch as intended, still needs a device.
colonelpanic8 added a commit to colonelpanic8/paseo that referenced this pull request Aug 1, 2026
getpaseo#2657 grew its own CallStyle implementation while this branch carried one.
Theirs is the better of the two and wins outright: it caught that a channel's
importance cannot be raised once created, so moving IMPORTANCE_LOW ->
DEFAULT needs a new channel id with the old one deleted. This branch's
version left the channel at LOW, which would have taken CallStyle's degraded
treatment without any sign that it had. Theirs also silences the channel so
DEFAULT's alert does not beep over the app's own connect cue.

BackgroundCallService.kt is therefore theirs verbatim, with only the two
CallAudioRouter hooks re-applied. Git's auto-merge had kept both CallStyle
blocks — adjacent enough to merge, wrong enough to render the call twice.

Audio routing is untouched by any of this and stays: getpaseo#2657 does not select a
communication device, so without CallAudioRouter the call still never reaches
a Bluetooth headset's microphone, which is what puts it on a watch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant