feat: Codex voice conversations on web, desktop, and mobile#13168

closed
t3code/codex-turn-mapping
GitHub fork: t3code/codex-voice-mode
openedSep 23, 2026byJjakeleventhal
View on GitHubTwo-way sync enabledPR last imported 21d ago

https://github.com/user-attachments/assets/c17005c8-2206-4058-bcb5-a58018c3dde6

https://github.com/user-attachments/assets/a89c7fd2-d419-4334-ad3d-cee3a84f4755

Codex 0.156 shipped voice conversations: you talk to a realtime voice model, it hands coding work to Codex in the thread, and it reads Codex's answer back. T3 Code had no way to do this.

How it works

  • Audio stays off the T3 socket. The client owns the mic, speaker, and WebRTC peer (oai-events data channel), and audio flows directly between the client and OpenAI. The new provider.voice.session stream RPC carries only the SDP handshake, captions, and lifecycle, so voice costs the same locally, over a relay, or through a tunnel.
  • The stream is the conversation. Unsubscribing or disconnecting stops the Codex realtime session, so a dropped client can't leave a billed session running.
  • Server (CodexVoice.ts, CodexAdapterV2). Starts thread/realtime/start with the same V3 WebRTC, client-managed-handoff settings the Codex TUI uses. When a voice turn finishes, its final answer goes back through thread/realtime/appendSpeech; commentary and reasoning are never spoken.
  • Idle-thread handoffs. Codex starts or steers the delegated turn itself, and that can't be turned off. Steering an active run needs nothing special. On an idle thread V2 has no provider-initiated run, so the adapter interrupts Codex's turn and, once it settles, offers a voice continuation. The request then runs as an ordinary user message. The tradeoff: Codex's own history keeps a stub of the interrupted turn. First-class provider-initiated runs would remove this.
  • Clients. A shared VoiceModeController in client-runtime handles negotiation, mute, captions, mic/speaker levels, the connect timeout, and teardown. Browsers and react-native-webrtc share the same structural peer API.
  • Web/desktop. Waveform button in the composer, /voice, F8 (voice.toggle), an unbound voice.toggleMute, command palette actions, and a voice strip with status, meters, mute/stop, and live captions. Captions are ephemeral.
  • Desktop. Grants mic access to the app origin only, asks macOS on first use, and adds NSMicrophoneUsageDescription and the audio-input entitlement.
  • Mobile. Adds react-native-webrtc (native rebuild required), Android RECORD_AUDIO, loudspeaker routing, and the same button and strip. Conversations stop on background or when leaving the thread.
  • Settings and scope. Voice is chosen in Codex provider settings. It's Codex-only (ServerProvider.supportsVoice) and needs a thread whose provider session is running.

Verification

  • Typecheck is clean in contracts, shared, client-runtime, server, web, desktop, and mobile.
  • Focused tests: 188 server (voice module, V2 Codex adapter, continuation service), 7 client-runtime, 88 web, 74 desktop, 69 shared keybindings, and 5 mobile.
  • Codex 0.156's app-server accepts the exact start params.
  • Installed and launched on desktop and iPhone from a personal build. A full spoken round trip has not been verified yet, and no screenshots are attached.

๐Ÿค– Generated with Claude Code (Claude Opus 5.5, running in T3 Code)

Checks

18 successful ยท 9 skipped ยท 1 neutral
All checks passed
b1231ab
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.tsline 5124Outdated ยท 26b06e9Open thread
โ€ฆโ€ฆ@@ -5081,6 +5114,27 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5121+ updated.delete(payload.turn.id);
5122+ return updated;
5123+ });
5124+ const voiceThread = yield* Ref.get(voiceProviderThread);
MmacroscopeappBoton line 5124Sep 23, 2026

๐ŸŸ  High Adapters/CodexAdapterV2.ts:5124

A late turn/completed for voice session A is enqueued against the current voiceProviderThread, so if session B starts before A completes, Aโ€™s spoken request is submitted to thread B and can run against Bโ€™s context/files. Store the originating voice thread with each voiceResubmits entry and use that captured thread when offering the continuation.

No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669

Fixed in 17b165e71b. Each voiceResubmits entry now captures the provider thread that was active when Codex started the turn, and the continuation uses that thread, not the current voiceProviderThread.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.tsline 3857Outdated ยท 26b06e9Open thread
โ€ฆโ€ฆ@@ -3825,6 +3837,27 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
3854+ );
3855+ yield* client
3856+ .request("turn/interrupt", { threadId: payload.threadId, turnId: payload.turnId })
3857+ .pipe(Effect.ignore, Effect.forkIn(sessionScope));
MmacroscopeappBoton line 3857Sep 23, 2026

๐ŸŸ  High Adapters/CodexAdapterV2.ts:3857

A voice handoff can execute twice: if turn/interrupt loses a race with a fast native turn or fails, the completed turn still consumes voiceResubmits and resubmits the same prompt as an ordinary run, duplicating tool and file operations. Do not ignore the interrupt outcome; only resubmit after confirming that the native turn was actually interrupted.

No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669

Fixed in 17b165e71b. The resubmit now runs only when Codex reports the turn interrupted. If the interrupt loses the race or fails, the turn already did the work and its answer is spoken, so nothing runs twice. Covered by the new resubmits a voice handoff Codex started only after its turn is interrupted test, which fails without the check.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/server/src/ws.tsline 2264Outdated ยท 26b06e9Open thread
โ€ฆโ€ฆ@@ -2231,6 +2232,53 @@ const makeWsRpcLayer = (
2261+ `Provider '${runtime.driver}' does not support voice conversations.`,
2262+ );
2263+ }
2264+ return runtime.startVoiceSession({ providerThread, offerSdp: input.offerSdp });
MmacroscopeappBoton line 2264Sep 23, 2026

๐ŸŸ  High src/ws.ts:2264

Starting a replacement voice session drops the new session's provider-thread association, so subsequent delegated voice turns fail the voiceThread !== undefined check and are never resubmitted to Codex. CodexVoice.start closes the previous stream, whose unconditional Stream.ensuring(Ref.set(voiceProviderThread, undefined)) then clears the ref after the replacement has set it; the finalizer must only clear the ref if it still belongs to the stream being finalized.

Also found in 1 other location(s)

apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts:5876

Stream.ensuring clears the shared voiceProviderThread unconditionally. When a second client starts voice, voice.start deliberately closes the previous stream, whose finalizer then sets the ref to undefined even though the replacement stream is active. A subsequent delegated voice turn cannot pass the voiceThread !== undefined check in turn/completed, so its continuation is dropped and the spoken request never runs.

๐Ÿค– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/server/src/ws.ts around line 2264:
2
3Starting a replacement voice session drops the new session's provider-thread association, so subsequent delegated voice turns fail the `voiceThread !== undefined` check and are never resubmitted to Codex. `CodexVoice.start` closes the previous stream, whose unconditional `Stream.ensuring(Ref.set(voiceProviderThread, undefined))` then clears the ref after the replacement has set it; the finalizer must only clear the ref if it still belongs to the stream being finalized.
4
5Also found in 1 other location(s):
6- apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts:5876 -- `Stream.ensuring` clears the shared `voiceProviderThread` unconditionally. When a second client starts voice, `voice.start` deliberately closes the previous stream, whose finalizer then sets the ref to `undefined` even though the replacement stream is active. A subsequent delegated voice turn cannot pass the `voiceThread !== undefined` check in `turn/completed`, so its continuation is dropped and the spoken request never runs.

Fixed in 17b165e71b. A stream's cleanup now clears voiceProviderThread only if it still holds that stream's own provider thread, so a replaced conversation can't clear its successor's thread.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/server/src/provider/Layers/CodexVoice.tsline 260Outdated ยท 26b06e9Open thread
โ€ฆโ€ฆ@@ -0,0 +1,292 @@
257+ expectedHandoffs: [],
258+ closed: false,
259+ };
260+ const previous = yield* Ref.getAndSet(activeRef, active);
MmacroscopeappBoton line 260Sep 23, 2026

๐ŸŸ  High Layers/CodexVoice.ts:260

Starting start for thread B terminates the active voice session for thread A, so concurrent voice conversations on different threads cannot work. activeRef stores only one ActiveVoice, and this code unconditionally calls finish(previous) and thread/realtime/stop without checking providerThreadId; track active sessions by thread (and keep finalizers scoped to their own entry) instead.

No longer relevant as of 46603d506211526cc3da2937eed5918ae485c699

Not a bug in this design. makeCodexVoice is built per app-server client, and in V2 each client belongs to one thread's provider session (openSession opens one client per threadId). Two conversations on different threads therefore have separate activeRefs and never replace each other. A second start on the same thread replaces the first on purpose: Codex runs one realtime session per thread, and the newer client takes over.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/mobile/src/features/threads/ThreadComposer.tsxline 530Outdated ยท 26b06e9Open thread
โ€ฆโ€ฆ@@ -502,13 +505,38 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
527+ const canToggleVoiceConversation =
528+ voiceConversationLive ||
529+ (props.connectionState === "connected" &&
530+ props.selectedThread.latestUserMessageAt !== null &&
MmacroscopeappBoton line 530Sep 23, 2026

๐ŸŸ  High threads/ThreadComposer.tsx:530

The voice button is enabled during initial thread creation, so tapping it starts the microphone flow before providerThread/providerSessionId exists and the server rejects it with "Send a message before starting voice." Because optimistic pending-thread shells already set latestUserMessageAt, that timestamp is not a valid session-readiness check; gate this condition on the threadโ€™s creation/provider-session-ready state instead.

No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669

Fixed in 17b165e71b. The mobile button now waits for activeProviderThreadId, which the server sets once the provider thread exists, instead of latestUserMessageAt, which an optimistic pending thread already has. This matches web's gating.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/mobile/src/features/threads/ThreadComposer.tsxline 513Outdated ยท 26b06e9Open thread
โ€ฆโ€ฆ@@ -502,13 +505,38 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
510+ [props.environmentId, props.selectedThread.id],
511+ );
512+ const voicePhase = useVoiceModePhase(voiceTarget);
513+ useVoiceModeThreadLifecycle(voiceTarget);
MmacroscopeappBoton line 513Sep 23, 2026

๐ŸŸ  High threads/ThreadComposer.tsx:513

A live voice session remains active while this mounted composer is hidden, leaving the user unable to stop or mute the recording and continuing the WebRTC session (and billing). useVoiceModeThreadLifecycle only cleans up on unmount or thread changes, but ThreadDetailScreen hides the composer for user-input requests and the settings sheet can cover it without unmounting or changing focus. Make the lifecycle stop or pause the session when the composer is hidden and when the route loses focus, rather than relying only on unmount cleanup.

๐Ÿค– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/mobile/src/features/threads/ThreadComposer.tsx around line 513:
2
3A live voice session remains active while this mounted composer is hidden, leaving the user unable to stop or mute the recording and continuing the WebRTC session (and billing). `useVoiceModeThreadLifecycle` only cleans up on unmount or thread changes, but `ThreadDetailScreen` hides the composer for user-input requests and the settings sheet can cover it without unmounting or changing focus. Make the lifecycle stop or pause the session when the composer is hidden and when the route loses focus, rather than relying only on unmount cleanup.

Fixed in 17b165e71b. ThreadDetailScreen now renders the voice strip (status, mute, stop) above the question card whenever a user-input request hides the composer during a live conversation on that thread.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

The question-card case is covered by 17b165e71b. Screens pushed over the thread (files, review, settings sheet) keep the conversation running on purpose, like a call, and the controls come back with the thread, so we are not stopping on blur; b1231abc19 updates the hook comment to say so.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/mobile/src/features/voice-mode/voiceMode.tslines 108โ€“108Outdated ยท 26b06e9Open thread
โ€ฆโ€ฆ@@ -0,0 +1,117 @@
105+ }
106+ const activation = keepAwake;
107+ keepAwake = null;
108+ activation?.then(() => deactivateKeepAwake(KEEP_AWAKE_TAG)).catch(() => undefined);
MmacroscopeappBoton lines 108โ€“108Sep 23, 2026

๐ŸŸ  High voice-mode/voiceMode.ts:108

The old conversation can deactivate the new conversation's wake lock, allowing the device to auto-lock and terminate an active voice session. Because every conversation uses the same "voice-mode" tag, the cleanup continuation at line 108 cannot distinguish an old activation from the current one; guard deactivation against a newer keepAwake activation (or use a unique per-conversation tag).

No longer relevant as of 17b165e71bffde1dc1913f1aebbd86488839d669

Fixed in 17b165e71b. The deferred cleanup now releases the shared voice-mode tag only when no newer conversation has taken the wake lock (keepAwake === null at release time).

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable โ€” This PR adds a substantial cross-platform Codex voice workflow with WebRTC, microphone permissions, native entitlements, new server orchestration, and an authenticated streaming RPC. It also changes the default F8 shortcut and adds static-analysis suppressions, so the scope and side effects warrant human review.

You can add or adjust custom eligibility rules. Learn more.

apps/web/src/components/chat/ChatComposer.tsxlines 7511โ€“7513Outdated ยท 17b165eOpen thread
โ€ฆโ€ฆ@@ -7409,6 +7498,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7510+ className={cn(
7511+ voiceEngaged && "bg-accent text-foreground",
7512+ "aria-disabled:cursor-not-allowed aria-disabled:opacity-64",
7513+ )}
MmacroscopeappBoton lines 7511โ€“7513Sep 23, 2026

chat/ChatComposer.tsx:7513

Primitive override: this call site overrides Buttonโ€™s base colors for its pressed state. Add a selected/pressed variant to the Button primitive (or use the existing Toggle primitive) instead of applying bg-accent text-foreground here.

No longer relevant as of 2ace4c3c396880dac82800a249c37c1bbfb2fd4c

Fixed in 2ace4c3c39. The voice button is now the Toggle primitive (variant="ghost", pressed), so the pressed state comes from its data-pressed styles instead of overriding Button colors.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Synced from GitHub

Ignore my attempted follow-up review submission; it was not posted. The submitted inline comment on ChatComposer.tsx is the only UI consistency finding from this check.

Posted via Macroscope โ€” UI Consistency

apps/server/src/provider/Layers/CodexVoice.tslines 89โ€“95Outdated ยท 17b165eOpen thread
โ€ฆโ€ฆ@@ -0,0 +1,292 @@
92+ override get message(): string {
93+ return this.detail;
94+ }
95+}
MmacroscopeappBoton lines 89โ€“95Sep 23, 2026

Layers/CodexVoice.ts:95

CodexVoiceSessionError is a service failure but uses Data.TaggedError and exposes the provider's arbitrary wire message through detail/message. Effect service failures should use Schema.TaggedError with structured, bounded attributes (for example a normalized stage/category), deriving a stable message from those fields rather than copying remote text.

Suggested fix: No self-contained diff is feasible because this requires updating the error schema, construction site, and downstream translation.

No longer relevant as of 46603d506211526cc3da2937eed5918ae485c699

Fixed in 2ace4c3c39. CodexVoiceSessionError is now a Schema.TaggedError. detail stays a string on purpose: it's Codex's thread/realtime/error message, the only explanation of why realtime failed (auth, quota, unsupported plan), and the user needs to see it. Normalizing it into a fixed category would throw that away.

packages/contracts/src/provider.tslines 184โ€“196Outdated ยท 17b165eOpen thread
โ€ฆโ€ฆ@@ -142,6 +142,58 @@ export class ProviderUploadFeedbackError extends Schema.TaggedError<ProviderUplo
193+ return this.detail;
194+ }
195+}
196+
MmacroscopeappBoton lines 184โ€“196Sep 23, 2026

This RPC error makes arbitrary detail text part of the public error contract and derives message directly from it; callers also populate it from lower-level cause.message. Replace detail with structured, bounded fields and derive stable caller-visible messages from them. Preserve the immediate underlying error only in cause when wrapping a real failure.

Suggested fix: No self-contained diff is feasible because all construction sites and client-facing messages must be migrated together.

Posted via Macroscope โ€” Effect Service Conventions

Keeping this as is. detail is the user-facing reason the conversation couldn't start or ended, e.g. "Send a message before starting voice." or Codex's own realtime error, and the client shows it directly. That matches existing contract errors that carry a detail: Schema.String (git.ts, keybindings.ts, the provider adapter errors). The underlying failure is kept separately in cause, and the text is bounded by what Codex sends. A structured error taxonomy for voice failures would be a separate change, not something this PR needs.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/server/src/provider/Layers/CodexVoice.tslines 109โ€“110Outdated ยท 2ace4c3Open thread
โ€ฆโ€ฆ@@ -0,0 +1,294 @@
107+ readonly expectedHandoffs: Array<string>;
108+ closed: boolean;
109+}
110+
MmacroscopeappBoton lines 109โ€“110Sep 23, 2026

Layers/CodexVoice.ts:110

makeCodexVoice injects an Effect service instance directly, hiding the CodexAppServerClient requirement from the constructorโ€™s Effect environment. Acquire it with yield* CodexClient.CodexAppServerClient inside makeCodexVoice and expose that requirement through make/layer; callers should compose the client layer rather than pass client["Service"].

Suggested fix: No self-contained diff is feasible because the constructor and all composition call sites must change together.

No longer relevant as of 46603d506211526cc3da2937eed5918ae485c699

Keeping the parameter. makeCodexVoice runs inside CodexAdapterV2.openSession, where each session's app-server client comes from clientFactory.open(...), one per provider session. That client is never provided as a CodexAppServerClient service in the Effect context, so reading it with yield* would require wrapping each call in Effect.provideService with the same instance, with no benefit. Helpers bound to a specific client already take it this way, e.g. CodexProvider.ts (client: CodexClient.CodexAppServerClient["Service"]).

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/ChatView.tsxline 8305Outdated ยท 67f778eOpen thread
โ€ฆโ€ฆ@@ -8287,18 +8289,31 @@ export default function ChatView(props: ChatViewProps) {
8302+ if (parsedSlashCommand === "voice") {
8303+ // Providers without voice receive "/voice" as a normal message.
8304+ const voiceAvailability = resolveThreadVoiceAvailability(activeThread, providerStatuses);
8305+ if (voiceAvailability.kind !== "unsupported") {
MmacroscopeappBoton line 8305Sep 23, 2026

๐ŸŸก Medium components/ChatView.tsx:8305

When a voice conversation is already active but resolveThreadVoiceAvailability returns unsupported, submitting /voice sends it as a normal turn instead of stopping the conversation. The kind !== "unsupported" guard skips toggleThreadVoice even though it is intended to stop an active target; allow the active-voice stop path to toggle before treating /voice as literal text.

No longer relevant as of 46603d506211526cc3da2937eed5918ae485c699

Fixed in 46603d5062. /voice now reaches toggleThreadVoice whenever a conversation is active on this thread, so it stops it even if the provider no longer reports supportsVoice. For providers without voice and no conversation running, it's still sent as a normal message.

apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.tslines 5136โ€“5136Outdated ยท 67f778eOpen thread
โ€ฆโ€ฆ@@ -5081,6 +5123,29 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5133+ // Resubmit only a turn Codex confirms it stopped. If the
5134+ // interrupt lost the race, Codex already did the work (and the
5135+ // voice speaks its answer), so running it again would repeat it.
5136+ if (payload.turn.status === "interrupted" && continuationRequests !== undefined) {
MmacroscopeappBoton lines 5136โ€“5136Sep 23, 2026

๐ŸŸ  High Adapters/CodexAdapterV2.ts:5136

A voice request is silently dropped when its Codex turn finishes with failed: this branch removes voiceResubmits and returns without offering the captured text to continuationRequests. Because no activeTurns context remains, the normal completion path cannot recover it; handle failed like interrupted when offering the continuation.

โœ… Resolved in 46603d506211526cc3da2937eed5918ae485c699

Fixed in 46603d5062. A voice turn Codex started that ends as failed is now resubmitted like an interrupted one, so the spoken request isn't dropped. Only completed (the interrupt lost the race and Codex already did the work) is skipped. The adapter test now covers completed, interrupted and failed.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

packages/shared/src/keybindings.tsline 66Outdated ยท 9d8c964Open thread
โ€ฆโ€ฆ@@ -63,6 +63,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
6363 { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" },
6464 { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" },
6565 { key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" },
66+ { key: "f8", command: "voice.toggle", when: "!terminalFocus" },
MmacroscopeappBoton line 66Sep 24, 2026

๐ŸŸก Medium src/keybindings.ts:66

This default makes voice.toggle win over an existing user binding for bare F8 whenever the terminal lacks focus, so the user's command becomes unreachable in that context. syncDefaultKeybindingsOnStartup only detects conflicts when the when strings match, and the resolver searches from the end; update the migration/conflict handling so this conditional default does not override a pre-existing bare f8 binding.

๐Ÿค– Copy this AI Prompt to have your agent fix this:
text
1In file @packages/shared/src/keybindings.ts around line 66:
2
3This default makes `voice.toggle` win over an existing user binding for bare `F8` whenever the terminal lacks focus, so the user's command becomes unreachable in that context. `syncDefaultKeybindingsOnStartup` only detects conflicts when the `when` strings match, and the resolver searches from the end; update the migration/conflict handling so this conditional default does not override a pre-existing bare `f8` binding.

Fixed in b1231abc19. Startup sync now skips a default when its shortcut matches a user rule and either side is unconditional, so a bare user f8 keeps working and voice.toggle is not appended.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/server/src/ws.tsline 2302Outdated ยท 9d8c964Open thread
โ€ฆโ€ฆ@@ -2293,6 +2294,53 @@ const makeWsRpcLayer = (
2299+ WS_METHODS.providerVoiceSession,
2300+ Stream.unwrap(
2301+ Effect.gen(function* () {
2302+ const voiceError = (detail: string) =>
MmacroscopeappBoton line 2302Sep 24, 2026

voiceError only forwards its argument into new ProviderVoiceSessionError, which adds an indirection without normalizing or translating the failure. Consider constructing the error at each validation branch instead.

Suggested fix: No self-contained diff is feasible because the helper and its three call sites need separate edits.

Posted via Macroscope โ€” Effect Service Conventions

Keeping it. The helper binds threadId once for three validation branches, and inlining would repeat the same constructor three times without making anything clearer.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Note

This comment is posted by Julius' dot

Closing under the prior-approval rule. This adds a new per-thread live voice workflow across web, desktop, and mobile, including voice-to-Codex handoffs. The recordings and focused checks demonstrate the proposal, but no maintainer approval of its direction and scope is linked. Please agree on that scope with maintainers, link their approval here, and request reconsideration.