Skip to content

Voice-call the session's AI agent from the share viewer (Boss Calling) - #345

Merged
kshivang merged 65 commits into
masterfrom
feature/webviewer-voice-call
Jul 26, 2026
Merged

Voice-call the session's AI agent from the share viewer (Boss Calling)#345
kshivang merged 65 commits into
masterfrom
feature/webviewer-voice-call

Conversation

@kshivang

@kshivang kshivang commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Boss Calling — voice-call the session's AI agent from the share viewer

Adds a Call BossTerm button to a bar along the bottom of the share viewer. Clicking it connects
the remote viewer by voice to an OpenAI Realtime agent that can read the shared terminal, run commands
in it, and narrate what it finds — so someone holding a share link can talk to the session instead
of typing into it. While a call is up that bar becomes the call strip: a live mic level meter, what the
agent is doing, Mute and End call.

Off-hours motivation: the viewer already lets a phone watch and steer a session; the missing piece
was doing that hands-free.

How it works

browser ──WebRTC audio──────────────────► OpenAI Realtime
   │                                          ▲
   │  voiceStart / voiceToolCall               │ ephemeral ek_… secret
   ▼  voiceSession / voiceToolResult           │ (minted host-side)
BossTerm host ────────────────────────────────┘
  1. Viewer clicks Call → mic permission is requested inside the click gesturevoiceStart.
  2. The host mints an ephemeral client secret (POST /v1/realtime/client_secrets, the GA endpoint)
    using the key stored on the host, baking in the model, voice, instructions, and tool schemas.
  3. The browser does the SDP exchange directly with OpenAI (POST /v1/realtime/calls?model=…).
    Audio never rides the tunnel or the share socket.
  4. Function calls arrive on the data channel and are forwarded over the existing share WebSocket;
    the host executes them against the shared session and replies voiceToolResult, which the
    viewer feeds back as function_call_output.

Second surface: "Call BossTerm" in the host window

The same feature also works with no browser at all. A pill in the host window's status strip (beside
Sharing) starts an in-app call: the host talks to its own terminal's agent.

Its posture is deliberately different, and worth reviewing separately:

share viewer in-app
transport browser ↔ OpenAI over WebRTC JDK WebSocket, host ↔ OpenAI
credential short-lived ek_… minted per call the host's standard API key
caller a remote guest holding a link the machine's owner
gating control role, share scope, mint budget, call token none — same trust as the user's own shell
audio browser's native stack javax.sound.sampled, PCM16 24 kHz mono

No new dependency: the Realtime API also speaks plain WebSocket, and the JDK has had both a
WebSocket client and audio capture/playback for years. Mic and speaker each run on a dedicated
thread (a capture loop blocks for the whole call — that is how BossTerm starved its IO dispatcher
once before), playback drains through a bounded queue so a slow speaker can't stall event dispatch
and break barge-in, and clicking End during "Connecting…" cancels the connect rather than letting it
resume and open the mic behind a torn-down call.

macOS packaging gained NSMicrophoneUsageDescription plus the audio-input entitlement in both
entitlement files — without them a packaged build is simply denied the mic.

Security

  • The API key never leaves the app. It lives in chmod-600 ~/.bossterm/voice.json (a separate
    file from settings.json, which is world-readable and ends up in bug reports) and is used only
    as the Authorization header on the mint call. The browser only ever sees a short-lived ek_…
    secret scoped to the exact tools and instructions the host baked in.
  • Control role required, server-enforced: starting a call and every write tool re-check
    canControl. A view-only viewer gets an explicit not_controller rather than silence.
  • Scope-limited: tool targets are hard-limited to the share's tabs/sessions — list_tabs is
    answered from the registry and filtered, and an out-of-scope tab_id is rejected. The agent
    cannot read or type into tabs the share doesn't cover.
  • connect-src names https://api.openai.com and nothing else new; it is the only remote origin
    the viewer may reach. Named unconditionally because CSP is baked at page load, so gating it on
    the enable toggle would break a call started after the host flipped it.
  • Mint failures log OpenAI's error.message + x-request-id host-side, while the remote caller
    gets only the status code — a 5xx body can name the org/project.

Tool surface

Curated 1:1 onto existing BossTerm MCP tools: list_tabs, get_active_tab, list_panes,
read_scrollback, search_output, get_last_command, and the write tools run_command,
send_input, send_signal. Deliberately excluded: run_in_panel (subsumed), show_image
(renders on the host screen — useless to a remote caller), read_debug_console, manage_tools,
and the daemon's open/close/resize_session.

Both share servers are supported through one VoiceCallService: the GUI executor invokes the MCP
handlers in-process (so it works even with the user's MCP endpoint disabled, and honors
disabledMcpTools for free), while the daemon executor maps onto DaemonMcpTools with the smaller
headless surface. The instructions template only describes the tools actually advertised, so the
agent never reaches for run_command on a daemon share.

Where the settings live

One shared BossCallingSection renders in Settings → Session Sharing and in both share
windows — the key can be added where the link is being handed out. Its status line mirrors exactly
what the viewer is told (voiceStatus): off / no API key yet / ready. Without that, a
missing key presents as no button at all in the browser, which is undiagnosable from the far end.

Two switches, because the surfaces are different trust boundaries:

  • voiceCallEnabled — the feature. Defaults on; storing a key is the deliberate opt-in, and with
    no key the host advertises itself unavailable and the viewer shows no button.
  • voiceCallShareEnabled — whether REMOTE share viewers may call. Currently defaults on; see the
    open policy question below. Turning it off leaves the in-app call working.
  • voiceShowStatusIndicator — whether the host window's status strip shows the segment, matching the
    existing per-indicator toggles for MCP and Sharing.

Testing

:compose-ui:desktopTest is green (566 tests). New coverage:

  • VoiceCallServiceTest (17) — enable/key gating, not_controller, tool-error paths, mint success
    and 401 against a loopback stub, a 5xx never forwarding OpenAI's error text to the viewer, the
    call-token gate (absent/forged/retired/expired), the kill switch stopping a call already running,
    mint rate limiting, the in-flight cap admitting exactly four, and refusing a new call at capacity
    rather than evicting a live one.
  • HostVoiceCallControllerTest (8) — the in-app call driven with a fake socket and fake audio: the
    session/audio handshake, base64 mic appends, mute stopping the stream, playback and barge-in, a
    two-call round emitting exactly one follow-up turn, call-id dedupe, refusing to open a socket
    without a key or with the switch off, teardown releasing both, and ending mid-connect never
    opening the mic.
  • GuiVoiceToolExecutorTest (8) — the scope gate (a named out-of-scope tab refused for every tool,
    a stale focus falling back without ever returning the foreign tab's data), read-only and prefixed
    embedder configs, and the argument-less tools answering with nothing focused.
  • VoiceAgentStorageTest (5) — round-trip, owner-only POSIX perms, corrupt file → null.
  • VoiceToolCatalogTest (3) — OpenAI tools JSON shape, write/guiOnly flags, required params.
  • DaemonVoiceToolExecutorTest (3) — tab_idsession_id mapping, scope rejection, GUI-only
    tools not advertised.
  • ShareProtocolTest — round-trip + "t" discriminator for all 7 new messages.
  • ShareViewerAssetTest — the CSP names api.openai.com exactly, no wildcard.
  • ShareViewerScriptTest (the existing Node harness that runs viewer.js) covers the new viewer
    code loading and initializing.

Manually verified against a running app: the served viewer carries the new CSP and markup, the
settings round-trip persists, and the host correctly advertises disabled / no_key / ready.

Known open policy question: one key and one voiceCallEnabled toggle currently gate both
surfaces, and both default on — so storing a key to try the in-app call also enables voice-driven
run_command for anyone already holding a control-share link. The host notification on call start is
an after-the-fact signal, not consent. A separate toggle for the remote surface (defaulting off), or
a one-time host confirmation on the first remote call, is worth deciding before merge.

Indirect prompt injection is inherent here: the agent reads scrollback — which an attacker can
influence via curl output, a build log, a README — while holding run_command. The mitigation
today is instruction-level only. A host-side approval for the first write tool of a call would be a
proportionate guard.

Not yet verified: a real end-to-end call. The key available for testing returns HTTP 500 from
every OpenAI endpoint — including GET /v1/models and a plain gpt-4o-mini completion — while a
deliberately bogus key gets a clean 401 from the same endpoint, so it is an account-side fault at
OpenAI rather than anything in this branch (the minimal possible mint body fails identically). The
audio path, live tool calls, and the mid-call failure toasts still need a working key before merge.

kshivang added 2 commits July 25, 2026 04:36
…ewer

Adds a "Call" pill to the share viewer that connects the remote user by
voice to an OpenAI Realtime agent able to inspect and act on the shared
session.

Audio goes browser↔OpenAI over WebRTC (never through the tunnel); the
host mints a short-lived ephemeral client secret from the user's own key,
and the agent's function calls ride the existing share WebSocket
(voiceToolCall → executed against the shared session → voiceToolResult).

- new compose/voice package: key storage (chmod-600 ~/.bossterm/voice.json),
  curated tool catalog, GUI + daemon executors, mint broker, shared service
- 7 additive share-protocol messages; wired into MirrorShare and
  DaemonShareServer, both gating on control role and share scope
- one shared "Boss Calling (voice)" section in Settings AND both share
  windows, so the key can be added where the link is handed out; its status
  line mirrors what the viewer is told, since a missing key otherwise shows
  as nothing at all in the browser
- on by default: storing a key is the deliberate opt-in, and with no key the
  host advertises itself unavailable
- viewer pill, mute/end controls, toasts; connect-src names api.openai.com
  (the SDP exchange), the only remote origin the viewer may reach
…sh model list

The GA SDP endpoint takes no URL parameters — any query string comes back as an
empty 400 — and the model is already bound to the ephemeral secret the host
mints, so passing it was both wrong and redundant. VoiceSession.model is now
purely informational: the viewer shows it on the Call pill during a call.

Also refreshes the model choices, which had gone stale: gpt-realtime-2.1
(new default), gpt-realtime-2.1-mini, gpt-realtime-2, gpt-realtime.

Verified against the current API docs: the mint body is correct as-is
(expires_after{anchor,seconds}, session.type=realtime, output_modalities,
audio.input.turn_detection semantic_vad, audio.output.voice, tools,
tool_choice), as are the data-channel event names and the oai-events channel.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling

Nice piece of work — the architecture is the right one. Audio stays off the tunnel, the API key never leaves the host, every tool call is re-validated host-side rather than trusting the model's scoping, the mic prompt is inside the click gesture, and the mint error path deliberately keeps OpenAI's body host-side with the request id. The VoiceToolExecutor split so both share servers get one policy layer is clean, and the reasoning for the unconditional connect-src is written down where the next person will look for it.

Findings below, most-significant first. (Posting in two comments — it got long.)

1. handleToolCall doesn't honor the master switch (or the key)

VoiceCallService.handleStart checks canControl -> voiceCallEnabled -> key. handleToolCall (VoiceCallService.kt:99) checks only def.write && !canControl. There's also no notion of "this connection has a live call".

So an admitted viewer can post voiceToolCall frames straight onto the share socket — no voiceStart, Boss Calling toggled off, no API key configured at all — and get read_scrollback / search_output / get_last_command / list_tabs executed. A controller additionally gets run_command / send_input / send_signal.

For a controller the write tools are no escalation (they can already type). The read side is a genuine amplification for a view-only viewer: the share snapshot is clipped to webViewerScrollbackLines(...), while read_scrollback will hand back up to the whole buffer.

Fix: re-check settings().voiceCallEnabled at the top of handleToolCall, and ideally bind tool calls to a session id issued at mint time so a connection that never called can't drive the bridge. VoiceCallServiceTest asserts the enable/key gate on handleStart only — worth a companion case.

2. get_active_tab is the one path that skips the scope check

GuiVoiceToolExecutor.execute (GuiVoiceToolExecutor.kt:77) returns tabInfoJson(defaultTabId ?: anchorTabId) with no in-scope test, and tabInfoJson resolves through registry.findTab(...), which searches every registered state — i.e. every window on the host. defaultTabId is vc.voiceTabId, which any viewer sets by sending a focus message (Focus is handled before the control gate at MirrorShare.kt:370). Result: title + cwd of a tab the share doesn't cover.

DaemonVoiceToolExecutor.tabInfoJson (line 87) has the same shape against host.list().

Tab/session ids are random UUIDs and out-of-scope ids are never disclosed to the viewer, so this isn't practically exploitable today — but it's the only unchecked target in a surface whose KDoc says "Both hard-limit targets to the share's scope", and the fix is one line in each tabInfoJson. Rejecting an out-of-scope Focus.tabId at ingress would close it at the source too.

3. VoiceAgentStorage.save reports success after a failed write

_keyPresentFlow.value = config.openaiApiKey.isNotBlank() sits outside the runCatching. On any IO failure the settings UI flips to "A key is currently set" and the status line to "Ready — a viewer with control sees the Call button", while loadKey() re-reads disk, finds nothing, and the viewer still gets no pill. That's precisely the undiagnosable state VoiceAvailabilityLine was added to prevent. Move the assignment into the success path.

Related, same function: if Files.move throws, the staged temp file still holds the plaintext key and is left behind in ~/.bossterm/. 600 perms, so not a disclosure, but a finally { runCatching { tmp.delete() } } is cheap. (AuthStorage.save has the same leak — good place to fix the shared pattern.)

4. Mid-call, a host toggle strands the viewer with no way to hang up

updateVoicePill() opens with if (!voice.status || !voice.status.available) { hide pill; hide voicectl; return; }.

A voiceStatus{available:false} arriving while voice.state !== "idle" hides Mute and End while voice.pc is still connected and the mic is still streaming to OpenAI. Guard the early return with voice.state === "idle", or call endCall(true) on an availability drop.

5. response.create fires once per tool result

voiceToolResult handling sends conversation.item.create + response.create for every result. But the host permits 4 concurrent tool calls, and the viewer forwards every function_call in response.done.output[] — so parallel calls are an expected shape. The second response.create gets conversation_already_has_active_response, which lands in the user's face as an "Agent error:" toast. Track outstanding call ids and only response.create once the last output is submitted. (Same reason the tool -> live state flips back early with parallel calls.)

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

(review, part 2)

6. disabledMcpTools only carries over at server-build time

The KDoc says "createServer() already honors disabledMcpTools, so user tool-disables carry over to voice for free." True at build time only: GuiVoiceToolExecutor.server is by lazy, and the live-update path (applyDisabledSet) runs against BossTermMcpManager's own instance, not this private one. A tool disabled after the first call stays callable via voice until the share (or app) restarts. Either filter against settings.disabledMcpTools inside tools()/execute(), or rebuild on change.

7. allowWriteTools = true is hardcoded

GuiVoiceToolExecutor.kt:45 pins BossTermMcpConfig(allowWriteTools = true). An embedder shipping allowWriteTools = false for a deliberately read-only MCP surface still gets run_command / send_input / send_signal through Boss Calling. Should inherit the app's configured value.

8. Daemon shares never push a voiceStatus update

MirrorShare has voiceJob combining settings + keyPresentFlow and broadcasting. DaemonShareServer sends def.voiceService.status() at admit and never again — so a daemon host who pastes the key after the viewer connected leaves them with no Call button until they reload, which is the exact failure the status line exists to make debuggable. In a separate daemon process keyPresentFlow won't fire at all, so this probably wants a periodic re-stat or an explicit push.

9. No rate limit on voiceStart

Each one is a live POST /v1/realtime/client_secrets against the host's account and yields an ek_... good for 600s of Realtime audio; a controller can loop it. A per-connection cooldown / one-live-session-per-connection would bound both quota burn and the number of outstanding secrets.

While here — the description says the secret is "scoped to the exact tools and instructions the host baked in". A browser holding an ephemeral secret can session.update instructions and tools on the data channel, so the instructions (including the destructive-command confirmation rule) are advisory. The actual enforcement is the host-side re-validation in handleToolCall + the executors, which is the right design — just worth not over-claiming in the docs, since someone will later read that sentence as a security boundary.

Minor

  • inFlightToolCalls is check-then-increment, not atomic, and the counter is per-share, not per-connection (voiceService is one instance shared by all viewers). Overshoot is bounded and harmless; updateAndGet/compareAndSet plus a per-connection cap would stop one viewer starving another.
  • MirrorShare replies via outbox.trySend (the evicting lane) while DaemonShareServer uses outbox.sendControl (guaranteed). A large read_scrollback payload can evict queued pane output; and if a voiceToolResult were ever dropped, the viewer has no per-call timeout and voice.seenCalls[callId] is already set — the agent sits in "Running..." forever. A control-lane send plus a client-side tool timeout would make that unreachable.
  • voiceErrorText default branch: "Couldn't start the call" + (m.message ? ": " + m.message : ".") loses the terminating period whenever there is a message.
  • expiresAtMs is plumbed through the protocol to the viewer and never read. Either drop it or use it to warn/renew before expiry.
  • VoiceToolCatalog inlines fully-qualified kotlinx.serialization.json.JsonPrimitive(...) in three spots (and JsonObjectBuilder in the objectSchema signature) while importing other symbols from the same package — an import would match the file's own style.
  • VoiceAgentStorage._keyPresentFlow's initializer does file IO in the object's static init, which first runs from a composable collectAsState on the UI thread.
  • MirrorShare's comment on voiceService by lazy"no MCP machinery spins up until a viewer actually calls" — describes GuiVoiceToolExecutor.server, not this lazy: start() touches voiceService immediately inside voiceJob. Harmless, the comment is just attached to the wrong object.
  • ServerMessage.VoiceStatus.reason is currently unused viewer-side (voiceErrorText folds disabled and no_key into one string). Fine, just noting it's carried for nothing today.

Test coverage

Strong on the mint path (loopback stub for 200/401/5xx, and the 5xx test asserting account detail doesn't leak is a good one) and on the catalog shape. Gaps:


Agreed with holding on the unverified end-to-end call — the whole WebRTC + data-channel + live-tool-call path is untested against a working key, and #4/#5 are both in exactly that stretch of code. Worth also exercising a parallel tool call and a mid-call socket drop once a key is available.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review — Boss Calling (1/2: the significant stuff)

Really nice piece of work. The architecture is the right one, and the key decision — the host re-validates every tool call server-side instead of trusting the browser — is what makes it sound. A malicious viewer can session.update the tools and instructions on the data channel all it likes and it buys them nothing, because VoiceCallService.handleToolCall plus the executors' scope filters are the real boundary. The write/guiOnly flags, scope-filtered list_tabs, sanitized mint errors (status code out, error.message + x-request-id kept host-side), separate chmod-600 key file, and exact-origin CSP are all correct calls. Comments explain why throughout.

Findings in rough severity order.

1. voiceStart has no rate limit — unbounded spend on the host's OpenAI account

VoiceCallService.handleStart (VoiceCallService.kt:54) mints on every request: no cooldown, no per-connection cap, no check for an already-active call. Tool calls get MAX_IN_FLIGHT_TOOL_CALLS = 4; mints get nothing.

Each mint yields an ek_... valid 600s (expires_after.seconds = 600) usable directly against OpenAI, off the share socket, billed to the host. A controller looping {"t":"voiceStart"} over the WebSocket harvests unlimited concurrent realtime sessions, and the host can neither see nor revoke them.

Suggest a per-connection cooldown plus a cap on live mints per share, and consider a shorter seconds — the viewer connects immediately, so 600s is mostly blast radius. This is the one item I'd want fixed before merge.

2. VoiceAgentStorage.save reports success even when the write failed

VoiceAgentStorage.kt:54-68_keyPresentFlow.value = ... sits outside the runCatching. If the write or ATOMIC_MOVE throws (read-only home, cross-device /tmp fallback, full disk), the flow still flips to true. The host then reads "Ready — a viewer with control sees the Call button" and "A key is currently set", while loadKey() goes to disk and keeps answering no_key, so the viewer sees no pill. That's exactly the undiagnosable state the status line was added to prevent. Set it only on success, or recompute keyPresent(file) after the attempt.

Smaller, related: if Files.move throws, tmp (chmod-600, holding the plaintext key) stays in ~/.bossterm/ indefinitely. AuthStorage.save has the same shape so it's pre-existing, but try { ... } finally { tmp.delete() } is cheap and this one is an API key.

3. Prompt injection: terminal content → agent → run_command, with no host-side visibility

The agent reads scrollback and runs shell commands. Terminal output is often attacker-influenced (curl of a remote page, cat of a file in a cloned repo, a CI log), so an injected instruction there can steer the model into run_command/send_input. Today's only mitigation is a prompt rule ("get verbal confirmation for destructive commands") — a model instruction defending against a model instruction.

What's missing is any host-side signal: no toast, no log line, nothing when a remote caller's agent executes a command in the host's terminal. With voiceCallEnabled = true by default, the moment a user pastes a key every share link they've already handed out becomes an AI-executable surface, with no further confirmation from them.

Worth considering, neither large: log/notify host-side on each write tool call (the OSC 133 notification plumbing already exists); and either split write tools behind their own setting or default the master switch off. The "the key is the opt-in" argument is defensible, but the key is entered in a sharing pane and the consequence is remote command execution. At minimum, put the injection risk in the VoiceToolCatalog docstring so whoever extends the tool surface inherits the threat model.

4. Viewer watchdog and mint timeout are both 15s — a slow mint reliably loses the race

viewer.js:287 arms a 15000ms watchdog before sending voiceStart; VoiceSessionBroker.defaultClient() allows the mint itself requestTimeoutMillis = 15_000, plus WS round-trips on both sides. Any mint slower than ~14.5s gives the caller "your network may block WebRTC" — a wrong diagnosis — and burns a secret the host successfully minted a moment later. Push the watchdog well past the mint budget (25-30s).

5. Parallel function calls will collide on response.create

voiceHandleFunctionCall fires per call id, and each voiceToolResult sends both conversation.item.create and response.create (viewer.js:2763-2766). The Realtime API can emit several function_call items in one response.done, and the host runs them concurrently — that's what MAX_IN_FLIGHT_TOOL_CALLS is for. With two results the second response.create lands while the first response is still active, which the API rejects with conversation_already_has_active_response, surfacing as Agent error: ....

voice.seenCalls already tracks the ids; make it a pending set rather than a write-only dedupe — enqueue all outputs, send one response.create when the set drains. Same fix removes the state flap where the first result flips tool back to live while others are still running.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review — Boss Calling (2/2: smaller findings, tests, nits)

6. handleToolCall never checks voiceCallEnabled or key presence

handleStart validates enable + key; handleToolCall (VoiceCallService.kt:99) validates neither, and read tools don't require canControl. So any viewer — view-only, on a host with Boss Calling switched off and no key configured — can hand-craft {"t":"voiceToolCall","name":"search_output",...} and have it execute.

Not a privilege escalation (targets are scope-limited to tabs the viewer can already see), but it means "off" isn't actually off, and search_output compiles a caller-supplied regex against the buffer — a catastrophic-backtracking pattern is a free CPU sink on the host. An early if (!settings().voiceCallEnabled) return toolError(...) closes it.

7. The daemon never re-broadcasts voiceStatus

MirrorShare pushes status on settings/key change via voiceJob. DaemonShareServer only sends def.voiceService.status() at admit time — and keyPresentFlow is in-process while DaemonShareWindow (GUI process) is what writes the key file, so there's no cross-process signal at all. A daemon host who adds the key while a viewer is connected leaves that viewer with a permanently hidden Call pill until reload; one who switches Boss Calling off leaves the pill visible (clicking gets an explicit disabled error, so safe, just confusing).

Polling VoiceAgentStorage.keyPresent() alongside the existing settings watcher, diffed through distinctUntilChanged, would match GUI behaviour. At minimum extend the daemon-side comment: "re-checked from disk at admit time" is accurate but doesn't say that already-connected viewers never learn.

Test coverage

The new tests are good — the 5xx-doesn't-leak-proj_secretname case and assertTrue(!encodeServer(r).contains("sk-test")) are exactly the right instincts.

The gap: GuiVoiceToolExecutor has no test at all, while DaemonVoiceToolExecutor — the less privileged one — has three. The GUI executor is the one advertising run_command, and its targetTabId !in scope check is the security boundary for in-app shares. Worth mirroring the daemon's "out-of-scope sessions are rejected and filtered" test against a fake McpTerminalRegistry: that a tab_id in args is honoured over defaultTabId but still scope-checked, and that list_tabs omits out-of-scope tabs. Also untested: the MAX_IN_FLIGHT_TOOL_CALLS cap, and the not_controller-vs-write matrix on a daemon share.

Smaller notes

  • inFlightToolCalls is check-then-increment (get() >= MAX, then incrementAndGet()), so concurrent frames from multiple viewers can exceed 4. As written the cap is advisory; updateAndGet with a bound would make it real.
  • voiceService by lazy in MirrorShare is defeatedstart() touches it immediately inside the combine collector, so the lazy never delays anything. Harmless in practice (VoiceCallService construction is cheap, and GuiVoiceToolExecutor.server is separately lazy and is the expensive bit), but the comment overstates it.
  • endCall doesn't clear voiceAudioEl.srcObject — a stale remote MediaStream stays attached to the hidden <audio> between calls.
  • voice.seenCalls grows unbounded within a long call (reset only at call start). Trivial, and the pending-set refactor from 🌏 Implement IME (Input Method Editor) support for CJK languages #5 would bound it naturally.
  • DaemonVoiceToolExecutor.tabInfoJson hardcodes "isActive": true (line 94) while listTabsJson computes s.id == viewing — same concept, two answers.
  • VoiceToolCatalog style: kotlinx.serialization.json.JsonPrimitive(...) spelled out fully qualified in three places (worth an import); the send_signal enum block (lines 124-126) is formatted unlike everything around it; and objectSchema being inline/crossinline buys nothing for a list-building call made nine times at class init.
  • RTCPeerConnection() with no iceServers matches OpenAI's own sample and should work against their publicly-reachable endpoint, but a caller behind symmetric NAT gets no srflx candidate and lands on the "your network may block WebRTC" path. A public STUN server would widen coverage, though it'd need a CSP note — entirely your call.

On the unverified end-to-end path

Agreed that a key returning 500 on GET /v1/models is account-side, not branch-side. Worth noting that #4 and #5 both sit squarely in the untested region — a mint-latency race and a parallel-tool-call collision are exactly the kind of thing that only shows up on a real call, so they're the specific things to watch for once a working key lands.

…d call

Calling is a mode you're in, not a toolbar toggle, so it gets its own bar at
the bottom of the viewer instead of a badge among the top-bar chips.

Idle it is a single Call button. On a call it becomes a strip: a level meter,
what the agent is doing right now (Connecting… / Listening… / Agent speaking /
Working… / Muted), Mute, and End call.

The meter is real audio, not a canned animation — an AnalyserNode on the mic
while you talk and on the agent's own track while it answers — so "it can't
hear me" is visible at a glance. Without WebAudio the bars stay flat rather
than breaking the call.

The bar stacks above #keybar and both reserve space via #body's padding, so the
on-screen keys, the call controls and the terminal never overlap.

Harness: teach viewer-harness.cjs to drive a call (secure context, mic, WebRTC,
fetch) and add two scenarios — the full Call → live → tool → End flow, and a
socket drop mid-call. The runner now awaits scenarios so a promise-driven flow
can't lose its assertions to an unhandled rejection. This also pins the SDP
endpoint against the ?model= regression.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling (voice-call the session AI agent) — part 1/3

Read the whole diff. Well-structured feature: the VoiceToolExecutor split so one VoiceCallService serves both share servers is the right seam, the separate chmod-600 key file is the right call (and the reasoning about settings.json ending up in bug reports is exactly right), keeping audio off the tunnel is the right architecture, and the PR description is unusually honest about what is not verified. The harness additions that actually drive the WebRTC fakes are better than most JS coverage in this repo.

Findings most significant first. Nothing challenges the design, but items 1-4 are worth fixing before merge — they all sit inside the security envelope the PR explicitly claims.

1. voiceToolCall is not gated on the master switch or on a live call

VoiceCallService.handleToolCall checks the tool name, def.write && !canControl, and the in-flight cap — but never settings().voiceCallEnabled, never key presence, and never that a session was actually minted for this connection. The viewer authors these frames itself; nothing about them requires OpenAI to be involved. So:

  • With Boss Calling off, or with no API key configured, a viewer can still send voiceToolCall and get tools executed. status() correctly reports disabled/no_key and handleStart refuses — but the tool bridge stays open. The master switch documented on TerminalSettings.voiceCallEnabled does not switch this off.
  • Read tools carry no canControl requirement, so a view-only viewer gets list_tabs, get_active_tab, read_scrollback, list_panes, search_output, get_last_command — regardless of the feature being disabled.
  • read_scrollback has no server-side line cap (BossTermMcpServer.kt:443 clamps only to totalAvailable), so a view-only viewer can pull the entire scrollback, while the render path deliberately caps them at MAX_WEB_VIEWER_SCROLLBACK_LINES. That is a real widening of what view-only means.

Fix: re-check voiceCallEnabled + key presence in handleToolCall (same when as status()), and only accept tool calls on a connection that has a live minted session — stamp it on successful mint, clear on voiceEnd/disconnect. That also closes item 4.

2. get_active_tab skips the share-scope check in both executors

Every other path validates the target (GuiVoiceToolExecutor.kt:80-83), but get_active_tab returns before reaching it:

// GuiVoiceToolExecutor.kt:77
"get_active_tab" -> return tabInfoJson(defaultTabId ?: anchorTabId)

tabInfoJson resolves via registry.findTab(tabId), which is global across every registered TabbedTerminalState (McpTerminalRegistry.kt:67 — "across all registered states"). defaultTabId is vc.voiceTabId, set straight from viewer-supplied Focus.tabId / VoiceStart.activeTabId with no validation (MirrorShare.kt:368). A viewer naming an out-of-scope tab id gets back that tab title, cwd and isActive — the one thing the interface doc promises cannot happen. DaemonVoiceToolExecutor.kt:53 has the identical hole (host.list().firstOrNull { it.id == sessionId }, unfiltered).

Exploitability is limited: ids are random UUIDs (TerminalTab.kt:49, TerminalSessionCore.kt:57), so they cannot be guessed. But a viewer that legitimately learned ids while a share was ALL-scoped keeps working after the scope narrows, and this is a one-line asymmetry against a boundary enforced everywhere else. Better than patching get_active_tab: clamp voiceTabId to the in-scope set where it is assigned.

3. No rate limit on voiceStart — unbounded mints on the host OpenAI account

handleStart mints on every request: no cooldown, no cap on concurrent calls per share, no check that the connection already has a live session. A viewer with the link and control can loop voiceStart and mint arbitrarily many 10-minute Realtime sessions billed to the host. Each secret is scoped to the baked tools, but it is still a live Realtime session usable for general conversation on the host dime — and the baked instructions are not a security property regardless, since the client can session.update over the data channel. A per-connection cooldown plus one-live-session-per-connection closes the class in a few lines.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review — part 2/3

4. Disabling Boss Calling mid-call leaves the call running with no way to hang up

updateVoiceBar hides the entire #voicebar when status flips unavailable:

var available = !!(voice.status && voice.status.available);
voiceBarEl.classList.toggle("on", available);
if (!available) { voiceCallEl.classList.remove("on"); layoutForKeyboard(); return; }

End call lives inside that bar. So if the host toggles Boss Calling off during a call, the viewer loses the hangup control while the mic stays hot, the peer connection stays up, and (per item 1) the tool bridge keeps executing. Add if (!available && voice.state !== "idle") { endCall(true); toast("The host turned off voice calling."); }.

5. One response.create per tool result races with parallel function calls

case "voiceToolResult":
  voiceDcSend({ type: "conversation.item.create", item: {...} });
  voiceDcSend({ type: "response.create" });

MAX_IN_FLIGHT_TOOL_CALLS = 4, and voiceHandleFunctionCall explicitly handles a response.done carrying multiple function_call outputs — so multiple calls per turn are expected. With two outstanding, the first result fires response.create while the second output has not been submitted: the model answers without it, and the second response.create lands on an already-active response (surfacing as the generic case "error" toast). Track outstanding call ids and only send response.create when the set drains. The same counter would keep voicestate on "Working…" until the last one returns, instead of flipping back on the first.

6. GuiVoiceToolExecutor hardcodes allowWriteTools = true, ignoring embedder policy

BossTermMcpServer(config = BossTermMcpConfig(allowWriteTools = true)).createServer()

BossTermMcpConfig.allowWriteTools is documented as "for embedders that want LLMs to observe but not drive their shells" (BossTermMcpConfig.kt:88), and McpSettingsSection surfaces it as "disabled by this build configuration". An embedder shipping read-only MCP still gets send_input/send_signal through the voice path. The private server also ignores toolNamePrefix and the embedder tool-registration hook. Thread the app configured BossTermMcpConfig through (as BossTermMcpManager does) instead of constructing a fresh default.

7. disabledMcpTools is snapshotted once, so the "for free" claim decays

The class doc says createServer() already honors disabledMcpTools, so user tool-disables carry over to voice for free. True at construction — but BossTermMcpServer.kt:187 reads settingsManager.settings.value.disabledMcpTools once at build time, and the server is cached behind by lazy. A tool disabled after the first voice call in a share lifetime stays available to the agent for the rest of that share. Either rebuild per call (mints are rare, it is cheap) or filter against the live set in tools()/execute(). A doc correction at minimum.

8. VoiceAgentStorage.save flips keyPresentFlow even when the write failed

runCatching { ... }.onFailure { log.warn("Could not persist voice config: {}", it.message) }
_keyPresentFlow.value = config.openaiApiKey.isNotBlank()

On a failed write (read-only home, full disk, ATOMIC_MOVE unsupported) the settings UI shows "A key is currently set" and VoiceAvailabilityLine says "Ready", while load() returns null and status() reports no_key to the viewer — exactly the undiagnosable mismatch this section exists to prevent. Move the flow update into the success branch and surface the failure.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review — part 3/3

9. Test coverage

Solid on VoiceCallService policy, the mint paths, the daemon executor, and the viewer bar. The gaps:

  • No test for GuiVoiceToolExecutor at all — it is the class doing scope filtering for the larger, write-capable surface and the only one exercising the in-process MCP handler path. DaemonVoiceToolExecutorTest has the shape to copy.
  • No test that get_active_tab rejects an out-of-scope id (item 2). DaemonVoiceToolExecutorTest covers send_input and list_tabs but not this one.
  • No test that a tool call is refused with voiceCallEnabled = false (item 1) — today such a test would fail, which is the point.
  • No viewer test for status flipping to unavailable mid-call (item 4), or for two concurrent function calls (item 5).

10. Smaller notes

  • VoiceToolCatalog.kt:124 — inline fully-qualified kotlinx.serialization.json.JsonPrimitive three times inside putJsonArray; import it (also :154). Reads as a leftover.
  • GuiVoiceToolExecutor.execute forwards every viewer/model-supplied arg except tab_id verbatim to the MCP handler, so the catalog narrower schema is not an enforcement boundary. Concretely run_command also accepts panel and split_ratio (BossTermMcpServer.kt:1356), which re-enables the run_in_panel behaviour the catalog doc says was deliberately excluded, plus working_dir. Either whitelist args per tool or soften the "deliberately excluded" framing.
  • handleToolCall calls executor.tools() on the WS receive thread, which for the GUI executor triggers by lazy construction of a whole BossTermMcpServer on first call — blocking the receive loop.
  • EXEC_TIMEOUT_MS = 610_000 exceeds the 600s secret lifetime, so a stalled tool holds one of four in-flight slots past the point the call can still exist.
  • voice.seenCalls is never pruned for the life of a call. Harmless in practice; clearing per response would be tidier.
  • The daemon never re-broadcasts voiceStatus to connected viewers when the key appears on disk (only at admit time and on voiceStart), so a host adding a key from DaemonShareWindow while someone is watching leaves them with no Call pill until they reload. Since host-side diagnosability is the whole rationale for the status line, a periodic re-stat would close the loop.
  • StoredVoiceConfig is public while VoiceAgentStorage (its only user, plus an internal composable) is internal — making it internal keeps an API-key-bearing type off the library public surface.
  • CSP: naming https://api.openai.com unconditionally is the right call and the comment justifies it well. Worth noting for the record that it also permanently permits the viewer page to reach that origin for hosts that never enable voice — small and acceptable, but it is a widening.

I did not run a build or the test suite as part of this review, so the green claim is taken at face value. Agreeing with the PR own caveat too: with no end-to-end call yet, the mint response shape (value vs client_secret.value), the expires_after / audio.output.voice body layout, and the output_audio_buffer.started/stopped event names are all unverified against the live API. Those are the first things to check once a working key exists, since a mismatch in any of them fails silently into a stuck "Connecting…" or a permanently flat meter.

… "Call BossTerm"

The key row now matches every other settings action row (SettingsFilePicker):
one accent-filled 36.dp primary plus a quiet TextButton, instead of a
Danger-tinted filled block that read as foreign next to the Share window's own
buttons. Clear key only appears when there is a key to clear.

The viewer's button says "Call BossTerm" rather than a bare "Call", and the
settings copy + availability line name it the same way. The harness pins the
label so it can't drift.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review — Boss Calling

Read through the whole branch. The architecture is good: VoiceToolExecutor / VoiceSessionBroker / VoiceCallService split cleanly, the key never rides a protocol message (and there's a test asserting that), VoiceAgentStorage mirrors AuthStorage's atomic + chmod-600 pattern, error-text sanitization is deliberate and tested, and the CSP addition is narrow and asserted. The PR description is also unusually honest about what wasn't verified, which made this easier to review.

A handful of things I'd want fixed before merge, roughly in severity order.


1. get_active_tab escapes the share scope

GuiVoiceToolExecutor.kt:77 and DaemonVoiceToolExecutor.kt:53 dispatch get_active_tab to tabInfoJson(defaultTabId ?: anchor) without a scope check, unlike the generic path below which does if (targetTabId !in scope) throw.

defaultTabId is vc.voiceTabId, which is set straight from viewer-controlled input with no validation:

  • MirrorShare.ktis ClientMessage.Focus -> { vc.voiceTabId = msg.tabId; return }
  • DaemonShareServer.kt — same, plus msg.activeTabId?.let { vc.voiceTabId = it } on voiceStart

And the lookups are global: GuiVoiceToolExecutor.tabInfoJson uses registry.findTab(tabId), which McpTerminalRegistry resolves across every registered window; DaemonVoiceToolExecutor.tabInfoJson scans all of host.list().

So a viewer on a SESSION-scoped share sends {"t":"focus","tabId":"<some other tab>"}, then asks the agent "what tab am I on?" and gets back the title and cwd of a tab the share doesn't cover. That contradicts the scope guarantee in the PR description and in VoiceToolExecutor's KDoc.

Cheapest fix is to move the scope check above the local-tool when:

val target = args.stringArg("tab_id") ?: defaultTabId ?: anchorTabId
if (target !in scope) throw VoiceToolException("Tab $target is not part of this share")
when (name) {
    "list_tabs" -> return listTabsJson(scope, target)
    "get_active_tab" -> return tabInfoJson(target)
}

Worth also rejecting an out-of-scope tabId at the Focus/VoiceStart handler so voiceTabId can never hold a foreign id in the first place. DaemonVoiceToolExecutorTest is the natural home for a regression test — it already builds an in-scope/out-of-scope session pair.

2. response.create is very likely to error on the first real call

viewer.js:2845:

case "voiceToolResult":
  voiceDcSend({ type: "conversation.item.create", item: {...} });
  voiceDcSend({ type: "response.create" });

Two problems, both of which produce conversation_already_has_active_response:

  • Timing. The call is dispatched from response.function_call_arguments.done (viewer.js:459), which fires while the response is still generating. If the host answers quickly — read_scrollback on a local buffer is sub-millisecond — response.create lands before response.done.
  • Parallelism. Realtime emits parallel function calls by default, and response.done's output[] loop (viewer.js:465) forwards each of them. N results → N response.create. The host's own MAX_IN_FLIGHT_TOOL_CALLS = 4 implies you expect this.

The usual shape is: buffer the function_call_output items, and issue exactly one response.create once response.done has arrived and every outstanding call for that response has resolved. Given the PR notes a real end-to-end call was never made, I'd guess this is the first thing that breaks.

Related, smaller: the first voiceToolResult flips tool → live even with other calls outstanding, and output_audio_buffer.started is ignored while state === "tool" — so the meter colour and "Working…" label desync under parallel calls.

3. The master switch doesn't stop a call in progress

VoiceCallService.handleToolCall checks def.write && !canControl but never re-reads settings().voiceCallEnabled — only status() and handleStart do. Viewer-side, voiceStatus { available: false } just hides the bar (updateVoiceBar toggles .on); it doesn't call endCall.

Net effect: a host who flips Boss Calling off mid-call still has an agent that can run_command. For something documented as the kill switch, it should be one — re-check in handleToolCall, and have the viewer endCall(true) when voiceStatus goes unavailable while voice.state !== "idle".

4. VoiceAgentStorage.save reports success when the write failed

VoiceAgentStorage.kt:54-68_keyPresentFlow.value = config.openaiApiKey.isNotBlank() sits outside the runCatching. A failed write (read-only home, disk full, ATOMIC_MOVE unsupported) logs a warning and then flips the UI to "A key is currently set ✓", and MirrorShare's combine broadcasts available: true — while loadKey() still returns null, so every viewer gets a Call button that fails with no_key.

Set the flow inside the success path. The staged temp file also leaks on failure (writeText or move throwing) — worth an onFailure { tmp.delete() }.

5. Nothing rate-limits voiceStart, and tool calls aren't bound to a call

Two related gaps in VoiceCallService:

  • handleStart has no cooldown or per-connection cap. A controller (or a buggy reconnect loop) can spam voiceStart; each one is a billed POST /v1/realtime/client_secrets against the host's key. MAX_IN_FLIGHT_TOOL_CALLS covers tool calls but nothing covers mints.
  • The service has no concept of an active call. voiceToolCall is accepted from any connected viewer at any time, whether or not a secret was ever minted — so the share socket doubles as a tool RPC. For read tools that's roughly what a view-only viewer already sees rendered, so it's not an escalation, but it's more surface than intended and it's unmetered.

A per-connection mint cooldown plus a session id minted in voiceSession and echoed on each voiceToolCall would close both.

6. GuiVoiceToolExecutor ignores the embedder's MCP config

GuiVoiceToolExecutor.kt:45 builds BossTermMcpConfig(allowWriteTools = true) from scratch. The real config is constructed per-app (bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt:98) and handed to BossTermMcpManager. So:

  • An embedder who set allowWriteTools = false for a deliberately read-only MCP surface still gets run_command / send_input / send_signal exposed through voice. The KDoc says user settings carry over for free — true for disabledMcpTools, not for this.
  • A non-default toolNamePrefix would make server.tools.keys prefixed, so it.name in registered (GuiVoiceToolExecutor.kt:50) is false for everything and the surface silently collapses to list_tabs/get_active_tab. Not reachable today only because the config is fabricated locally.

Plumb the real BossTermMcpConfig in, or at least inherit allowWriteTools.

7. No host-side signal that a call is live

There's sessionSharingShowIndicator for the share itself, but a remote caller can open a voice call and drive commands with nothing visible on the host beyond the terminal scrolling. Given the feature ships enabled by default, an indicator (or a notification on call start, alongside the existing notifyOnCommandComplete machinery) seems worth it.


Smaller notes

  • TOCTOU on the in-flight capVoiceCallService.kt:114-120 does get() then incrementAndGet(); concurrent WS messages can admit more than 4. updateAndGet with a bound, or a CAS loop.
  • status() hits the disk on every settings changeloadKey() reads and parses voice.json, and MirrorShare's combine(settings, keyPresentFlow) re-evaluates it on any settings emission, per active share. keyPresentFlow already tracks presence in-process.
  • VoiceSession.expiresAtMs is dead — parsed in parseMint, serialized, never read by the viewer. Either drop it or use it (the secret is 600s; a long call has no re-mint path).
  • Daemon shares never re-push voiceStatus — only sent at admit, so adding a key while a daemon viewer is connected needs a reload. The comment acknowledges it, but the daemon does have broadcast() and a settings source.
  • Blocking I/O on the UI threadVoiceAgentStorage.save/clear are called directly from the Compose onClick in BossCallingSection.kt:194,203.
  • querySelectorAll per animation framevoiceMeterRun's tick re-queries voiceLevelEl.querySelectorAll("i") at 60Hz; the 12 bars are static, cache the NodeList once.
  • StyleVoiceToolCatalog.kt:124-126,154 uses fully-qualified kotlinx.serialization.json.JsonPrimitive(...) inline three times; import it. The send_signal enum block is also formatted much more densely than the rest of the file.

Test coverage

The policy surface is well covered — not_controller, enable/key gating, the 5xx-doesn't-leak-proj_secretname assertion, and the CSP exact-match test are all the right tests to have written. Gaps I'd fill:

  • No GuiVoiceToolExecutor test at all. It's the executor with the larger surface (write tools, MCP handler dispatch, scope filtering across windows) and the one where finding 1 has the wider blast radius, yet only the daemon executor is tested.
  • Nothing covers get_active_tab with an out-of-scope defaultTabId (finding 1).
  • Nothing covers MAX_IN_FLIGHT_TOOL_CALLS — the 5th concurrent call, and the counter returning to zero afterwards.
  • The viewer harness only asserts the new code loads/initializes. The data-channel sequencing in finding 2 is exactly the kind of thing ShareViewerScriptTest could pin down by feeding synthetic response.function_call_arguments.done / response.done events and asserting how many response.create frames come out.

kshivang added 2 commits July 25, 2026 14:32
…witch a kill switch

Review fixes, most consequential first.

1. get_active_tab / list_tabs escaped the share scope. Both executors dispatched
   the locally-answered tools before any scope check, and their lookups are
   global (McpTerminalRegistry.findTab spans every window; host.list() every
   session). defaultTabId is unvalidated viewer input, so a viewer on a
   SESSION-scoped share could Focus a foreign tab and read back its title and
   cwd. The check now precedes every dispatch, and Focus/VoiceStart refuse to
   store an id the share doesn't cover in the first place.

2. response.create fired mid-response and once per parallel call, which earns
   conversation_already_has_active_response. The viewer now buffers
   function_call_output and asks for exactly one follow-up turn, after
   response.done AND once every call of that round has resolved. Speaking and
   tool-busy became separate flags, so audio starting during a tool no longer
   desyncs the bar.

3. The master switch didn't stop a call already running. handleToolCall
   re-reads it and invalidates live calls; the viewer ends the call when
   voiceStatus goes unavailable instead of just hiding the bar.

4. VoiceAgentStorage.save flipped the UI to "key is set" even when the write
   failed — which also made shares advertise available:true while load()
   returned null. It now returns success, only publishes on success, cleans up
   the staged temp file, and the settings UI surfaces the failure. Writes moved
   off the Compose thread.

5. Nothing bounded mints or bound tool calls to a call. Added a mint gap +
   rolling window cap (each mint is billed against the host's key), and a call
   token minted in voiceSession and echoed on every voiceToolCall, so the share
   socket can't be driven as a standing tool RPC.

6. GuiVoiceToolExecutor fabricated its own BossTermMcpConfig, so an embedder's
   read-only surface still exposed run_command by voice and a toolNamePrefix
   would have collapsed the surface. The manager now publishes the real config
   on McpTerminalRegistry and the executor honours it, prefix included.

7. A call is remote hands on the machine with nothing visible host-side, and
   the feature ships enabled — call start/end now notify through the same
   channel as a share approval request.

Also: atomic in-flight claim (get-then-increment could exceed the cap), status()
answers from the in-process key flow instead of re-reading voice.json per
settings emission, daemon shares poll voice availability so a key added
mid-session reaches connected viewers, dropped the dead VoiceSession.expiresAtMs,
cached the meter's bars instead of re-querying at 60Hz, and imported
JsonPrimitive rather than fully qualifying it three times.

Tests: scope-escape regression (get_active_tab pointed out of scope, as claimed
current tab and named outright), no-token/forged-token refusal, kill switch
mid-call, mint rate limiting, and the harness now asserts the token echo, the
one-response.create-per-round contract, and revoke-ends-the-call.
Settings → Session Sharing is where someone looks to find out why the viewer
shows no Call BossTerm button, so it should answer that in place rather than
only in the Share window.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling — correctness & security (1/2)

Read the whole diff (27 files, ~2.8k lines). Unusually well-constructed for a feature this size: policy centralised in one VoiceCallService, the executor abstraction genuinely bounds the agent to the share's scope, the key kept out of settings.json in a chmod-600 file, mint errors split host-side/viewer-side, and the callToken handle closes the "share socket as standing tool RPC" hole before anyone could report it. The description is also honest about what wasn't verified. Findings roughly by severity.

(Reviewed by reading; I did not run a build — the PR states :compose-ui:desktopTest is green.)

1. watchVoiceStatus never runs for the first viewer of a daemon share

DaemonShareServer.kt:709 launches the watcher, but the viewer isn't registered until :925 (def.viewers.add(vc)), and the loop guard is while (def.viewers.isNotEmpty()). Between the two the admit path suspends repeatedly (send(layoutFor(def)), the per-session beginLocked snapshots), so the coroutine reaches its guard while def.viewers is still empty, exits immediately, and nulls voiceWatchJob.

So for a share with one connected viewer — the common case — the poll never runs, which is exactly the scenario it was written for: a key added mid-session never reaches the open browser tab, and revoking Boss Calling mid-call never triggers closeCalls(). A second concurrent viewer accidentally makes it work, which will make this awkward to reproduce. Fix: move watchVoiceStatus(def) after the def.viewers.add(vc) block, or guard on def in shares.

Two smaller things in the same function: voiceWatchJob?.isActive == true then assignment is a check-then-set on a @Volatile field (two concurrent admits can both launch, and finally { voiceWatchJob = null } can clear a newer job's handle); and lastVoiceStatus starting null means the first poll iteration always re-broadcasts a voiceStatus every viewer already got at admit — to all viewers.

2. GUI voice replies ride an evicting queue, with no timeout behind them

MirrorShare.kt:410 / :417 reply through vc.outbox.trySend(...), and BoundedViewerOutbox.trySend (MirrorShare.kt:1049) evicts the oldest queued frames under back-pressure. A queued voiceToolResult can be dropped by the very pane output the tool call just produced — run_command on something chatty is the obvious trigger. voiceSession (the minted secret) is exposed the same way. The daemon path is stronger: FrameOutbox.sendControl has a dedicated control lane and closes the connection rather than dropping (FrameOutbox.kt:112).

The browser can't recover either: voice.pendingCount only decrements on a voiceToolResult, so one lost reply parks the bar at "Working…" forever and voiceMaybeRequestResponse() never fires — the agent goes silent mid-sentence with no way out but End call. Ideally both fixes: put voice control messages on a non-evicting lane, and add a per-call timeout in voiceHandleFunctionCall (30–60s, longer for run_command) that submits {"error":"timed out"} as the function_call_output and clears the slot — that's the stated design goal for every other failure path.

3. The spend-limiting rationale is inverted

VoiceCallService.kt:50 / :220-224 says each mint is the billed request and "nothing else in the path costs money". It's the other way round: minting a client secret is free, the Realtime session is billed per audio token. So the limiter caps the free operation and leaves the expensive one unbounded — no cap on call duration, and expires_after: 600s bounds only when the secret can establish a call, not its length (nor how many calls one secret opens inside that window; audio goes browser→OpenAI, so the host never sees them). 12 mints/10min is a generous ceiling on concurrent billable sessions. Shippable without a hard budget, but the comment should say what's true, and a max call duration is worth considering — a remote guest currently controls the host's OpenAI spend.

4. voiceEnd doesn't end anything server-side

Both servers no-op it (MirrorShare.kt:414 and the matching daemon branch), so the call token stays valid for CALL_TOKEN_TTL_MS = 6 hours after hang-up; nothing server-side stops a replayed token from driving tools. And onCallActivity(false) is only reachable from closeCalls(), which only fires when the host disables the feature — so notifyVoiceCall's "The voice call on … ended" effectively never fires in normal use. The host gets "a viewer started a voice call, the agent can read and run commands here" and then silence, which undercuts the notification's stated purpose. Revoking the token on voiceEnd (and on viewer disconnect) fixes both.

Defence-in-depth: liveCalls is per-share, not per ViewerConnection, so a token is valid for any viewer of that share, including a view-only one for the read tools. Needs a token leak across two separately-encrypted sockets, so not urgent — but binding the token to the minting connection is nearly free.

5. MirrorShare doesn't kill live calls when the switch flips

DaemonShareServer.kt:1066 calls closeCalls() when status goes unavailable; the GUI equivalent (MirrorShare.kt:182-186) only broadcasts, leaving invalidation to the next handleToolCall. Mostly cosmetic since the viewer self-ends, but a viewer that ignores the message keeps working tools, and the host gets no "ended" notification. Worth making the two hosts symmetric.

6. disabledMcpTools carries over less than the KDoc claims

GuiVoiceToolExecutor.kt:53 builds the private server by lazy, and createServer() applies disabledMcpTools once at construction (BossTermMcpServer.kt:187); the live applyDisabledSet re-sync is driven against the manager's server, not this one. So the voice surface snapshots the disabled set at first call and keeps it for the life of the MirrorShare. Separately, LOCAL_TOOLS (GuiVoiceToolExecutor.kt:46, :60) are advertised unconditionally, so a user who disabled list_tabs/get_active_tab still exposes them by voice. Both small — either narrow the "carry over for free" claim or rebuild the server when the set changes.

7. Prompt injection reaches run_command

Not in the PR's Security section, but worth stating: the agent reads scrollback — often attacker-influenced (log lines, curl output, filenames, CI output) — and holds write tools. The only guard against "the scrollback told it to run something" is an instruction asking for verbal confirmation, which the same injected text can talk around; session.update on the data channel can also rewrite those instructions from the browser. Blast radius is genuinely bounded by the scope check and by the caller already being a controller who could type the command anyway, so I don't think it blocks — but it argues for host-side visibility beyond the one call-start notification (a read-only voice toggle, or a log line per write tool). At minimum the settings copy should say terminal contents and tab titles/cwds are sent to OpenAI; "can inspect this session and run commands" could be read as staying local.

8. Verify the default model id

voiceCallModel = "gpt-realtime-2.1". Since end-to-end was never exercised, please confirm this against the current Realtime model list — a wrong default makes every mint fail as "Couldn't start the call: OpenAI returned HTTP 400", which reads like a key problem, for a feature that's on by default.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling (PR #345)

Read the whole diff — the seven new voice/ files, both share-server integrations, the protocol additions, and viewer.js. This is unusually well-constructed for a feature this size: the security boundary is drawn in one place (VoiceCallService), the scope check is duplicated defensively at the executor, the key genuinely never crosses the wire, and the KDoc explains why rather than restating the code. Findings roughly in order of importance.

1. No watchdog on the tool round-trip — a call can wedge silently

There's a 15 s watchdog for connecting, but nothing guards a tool call once it's in flight. Three ways the call goes permanently mute with the bar stuck on "Working…":

  • Dropped reply. voiceToolResult goes out via vc.outbox.trySend(...) (MirrorShare.kt:410,417), and BoundedViewerOutbox.trySend evicts the oldest frames under back-pressure (MirrorShare.kt:1049). A run_command dumping lots of output onto a slow mobile link is exactly when the queue fills — and the result frame for that same call is a plausible eviction victim. voice.pendingCount never comes back down, so voiceMaybeRequestResponse() never fires again.
  • response.done never arrives (data-channel hiccup) → voice.responseOpen stays true forever, same outcome.
  • Long tool. EXEC_TIMEOUT_MS is 610 s and run_command takes timeout_ms; ten minutes of silence outlives the Realtime session anyway.

Suggest a per-callId timer in voiceHandleFunctionCall; on expiry synthesize a function_call_output of {"error":"the host did not respond"}, decrement pendingCount, and let voiceMaybeRequestResponse() run. That matches the intent already stated for isError results ("so the agent can recover verbally instead of the call dying").

Smaller edge in the same function: voice.outputsOwed = 0 happens before voiceDcSend, which silently no-ops on a closed channel — a transient dc state loses the turn with no retry. Zero it only after confirming dc.readyState === "open".

2. voiceEnd is ignored — tokens outlive the call by up to 6 hours

is ClientMessage.VoiceEnd -> return in both servers. Two consequences:

  • A callToken stays in liveCalls for CALL_TOKEN_TTL_MS (6 h) after hangup. That token is the thing stopping the share socket being used as a standing tool RPC, so leaving it valid long after the call it authorized ended weakens exactly the invariant openCall()/isLiveCall() exist to enforce. Dropping that connection's token on voiceEnd is a few lines.
  • onCallActivity(false) never fires on a normal hangup. It's only reachable from closeCalls(), which only runs when the feature is disabled (VoiceCallService.kt:139, DaemonShareServer.kt:1066). The host gets "A viewer started a voice call…" and never the matching "ended". Given the notification exists precisely because a call is "remote hands on this machine," worth fixing.

3. Prompt injection is the real threat model, and it isn't addressed

The privilege story checks out — the agent can only do what a controller could already do by typing, so this isn't an escalation. But the actor changes: an LLM that ingests read_scrollback/search_output and can call run_command. Terminal content is attacker-influenceable in ordinary ways (a curl of a README, git log from a fetched branch, output from a box you SSH'd into). The only mitigation today is the soft "get verbal confirmation for destructive commands" prompt rule — which asks the caller, the person being socially engineered by that same output. Worth at least one of: a docs/settings note that terminal content is untrusted input to the agent; surfacing each run_command on the host UI (the notification channel is already wired); or a "read-only calls" setting that drops the write tools from the catalog.

4. GuiVoiceToolExecutor has no unit test

DaemonVoiceToolExecutorTest covers scope rejection and id mapping, but the GUI executor — the one with run_command, toolNamePrefix resolution, and the args.stringArg("tab_id") ?: defaultTabId ?: anchorTabId gate at GuiVoiceToolExecutor.kt:87 — has none. That gate is the security boundary for the privileged surface; it deserves the same out-of-scope-tab_id test the daemon got, plus one that list_tabs/get_active_tab really are filtered to inScopeTabIds. Also untested in VoiceCallServiceTest: the MAX_MINTS_PER_WINDOW window cap (only the min-gap path is covered), CALL_TOKEN_TTL_MS expiry, MAX_LIVE_CALLS eviction, MAX_IN_FLIGHT_TOOL_CALLS. nowMs is already injectable, so the first three are near-free.

5. GUI and daemon disagree about clearing the key mid-call

watchVoiceStatus calls closeCalls() whenever daemon status goes unavailable (line 1066), including key removal. MirrorShare.voiceJob only broadcasts (line 185), and handleToolCall re-checks voiceCallEnabled but not key presence — so on a GUI share, clearing the key mid-call leaves it fully functional. Either behavior is defensible, but they should match; the Danger-tinted "Clear key" button claims it "turns the feature off for every viewer," which isn't currently true on the GUI path.

6. Viewer element lookups aren't guarded

Ten getElementById("voice…") at module scope, and voiceCallBtnEl.onclick = … runs at load (viewer.js:291). staticResources sets no explicit cache headers, so a returning viewer can hold a cached index.html against a fresh viewer.js — that line then throws a TypeError during script evaluation and the entire viewer breaks, not just the Call button. The coupling pattern is pre-existing, but this is the first change to add ten required elements at once. One guard around the wiring degrades to "no Call button" instead of a dead terminal.

7. Smaller

  • allowMint() appends the timestamp before the mint (VoiceCallService.kt:234), so twelve network failures exhaust the 10-minute budget without a single billed request. Recording only on MintResult.Ok matches the stated "spend limiter" intent.
  • VoiceAgentStorage._keyPresentFlow does disk I/O in an object initializer (line 34), on whatever thread first touches the object — likely the Compose thread. lazy or an explicit warm-up is tidier.
  • The mint limiter is per-share, so N shares multiply the budget N×. Worth a KDoc sentence so nobody reads it as host-wide.
  • MAX_LIVE_CALLS = 8 lets several controllers each drive an agent on the same terminal — fine as a bound, but a log.info on the second concurrent call would make that state debuggable.
  • MirrorShare.notifyVoiceCall fully-qualifies NotificationService inline (line 117) rather than importing it.

What's good

  • The double scope check (rememberVoiceTab filters inbound, the executor re-checks) with the comment explaining that get_active_tab spans every window on the host — a bug caught by reasoning, not by a test failing.
  • VoiceSessionBroker logging error.message + x-request-id host-side while the viewer gets only the status code, and a test asserting the 5xx body doesn't leak.
  • save() returning Boolean with the UI surfacing failure, instead of optimistically flipping to "key set" on a read-only home.
  • Building the in-process MCP server from the embedder's BossTermMcpConfig, so allowWriteTools = false and toolNamePrefix carry over rather than being fabricated.
  • chmod 600 applied to the temp file while it's still empty, before the key bytes land.
  • Pane targeting holds up: state.findSession(tabId, paneId) resolves pane_id strictly within the already-scope-checked tab, so forwarding un-advertised args verbatim doesn't open an escape.

On the unverified end-to-end path

Agreed the 500-on-/v1/models vs clean-401-on-bogus-key evidence points account-side. Two things I'd specifically exercise once a working key is in hand, since static review can't reach them: the parseMint GA-vs-beta fallback (VoiceSessionBroker.kt:122-124 — the beta shape is inferred, not observed), and the multi-function-call-per-response path through voiceMaybeRequestResponse, the trickiest piece of state in the diff.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling — performance, tests, nits (2/2)

Performance

  • voiceMeterRun writes 12 inline style.height values per animation frame for the whole call, each with transition: height 70ms attached — ~720 style mutations/sec on a phone alongside xterm rendering. ~20–25fps would look identical and cost far less.
  • The daemon poll is described as "one small-file stat" (VOICE_STATUS_POLL_MS), but the default keyPresent is loadKey() != null → a full readText() + JSON decode of voice.json every 5s per share. Fine in absolute terms; a lastModified() check before parsing would match the comment.
  • GuiVoiceToolExecutor.tools() is called synchronously from the WS receive loop in handleToolCall, forcing lazy MCP server construction on that thread. Not I/O, but handleStart already does this inside scope.launch — worth being consistent.

Test coverage

Strong: the loopback mint stub, the 5xx-doesn't-leak-account-detail test, the exact-CSP assertion, and driving the real viewer.js through the Node harness are all the right tests to have written. The harness change from forEach to an awaited for loop is a genuine fix — async scenarios would previously have lost their assertion failures to unhandled rejections. Gaps:

  • No GuiVoiceToolExecutorTest. The daemon executor has three good scope tests, but the GUI path is the one most shares take and the one with the harder logic (prefix resolution, the LOCAL_TOOLS bypass, the lazy server, tab_id injection into effectiveArgs). The daemon tests port almost directly.
  • Nothing covers watchVoiceStatus, where finding feat: Terminal rendering improvements + 4 high-priority features (#2, #3, #4, #5) #1 in the previous comment lives.
  • MAX_IN_FLIGHT_TOOL_CALLS and MAX_LIVE_CALLS / CALL_TOKEN_TTL_MS eviction are untested — cheap now that nowMs and openCall() are injectable.
  • VoiceAgentStorageTest drives the real VoiceAgentStorage singleton with an injected file, so save/clear mutate the process-wide _keyPresentFlow that other tests in the same JVM read. Harmless today, but a cross-test coupling waiting to bite.

Nits

  • endCall doesn't clear voiceAudioEl.srcObject, so the element retains the last call's MediaStream. Harmless (the pc is closed) but easy to tidy.
  • A stray or duplicate voiceToolResult increments voice.outputsOwed before the voice.pending[m.callId] check, so a result arriving with nothing pending fires a spurious response.create — one unprompted (billed) agent turn. Moving the increment inside that branch closes it. The harness scenario actually exercises this path (the callId: "c2" stray) but only asserts the aggregate count.
  • notifyVoiceCall uses a fully-qualified ai.rever.bossterm.compose.notification.NotificationService inline; the file has precedent for that style, but an import reads better.
  • AGENTS.md isn't updated — neither the Features Summary nor the settings list mentions Boss Calling or the new ~/.bossterm/voice.json, and per the repo guide that's the first place a future session looks.

Things I checked and found correct

Recording these so they don't get re-litigated:

  • pane_id can't escape the scope check — the MCP handlers resolve panes through state.findSession(tabId, paneId), i.e. within the already-validated tab.
  • The ephemeral secret goes to one viewer's outbox, never broadcast.
  • Moving Focus ahead of the !vc.canControl gate is behaviour-preserving (it was a no-op in the else branch before), and rememberVoiceTab correctly refuses to store an out-of-scope id even though the executor re-checks.
  • getAndUpdate for the in-flight slot claim is the right primitive, and the finally { decrementAndGet() } can't leak the slot.
  • Temp file is chmod-600 before the key is written, and ATOMIC_MOVE carries those perms onto voice.json.
  • synchronized { return false } inside the expression-bodied allowMint/isLiveCall is legal Kotlin (explicit return type + inline lambda).
  • The response.create gating on responseOpen + pendingCount correctly handles both the multiple-calls-per-response and result-before-response.done orderings.

Nice work on the security posture in particular: scope-checking before dispatch including the locally-answered tools (with a test naming exactly why that matters) and keeping the key out of settings.json are both the non-obvious right calls. Of the earlier list, #1 is what I'd fix before merge; #2 and #4 are the ones most likely to produce "the agent just stopped talking" reports in the field.

kshivang added 2 commits July 25, 2026 14:53
Adds a "Call BossTerm" pill to the host window's status strip, beside Sharing,
and an in-app call bar: talk to this terminal's agent from the app itself.

No new dependency and no WebRTC. The Realtime API also speaks plain WebSocket,
so the host connects over the JDK's own java.net.http WebSocket with its
standard API key (there's no untrusted party here, so no ephemeral secret), and
javax.sound.sampled handles the PCM16 24kHz mono capture and playback the API
expects.

- RealtimeTransport: the socket, behind an interface so the protocol is testable
- VoiceAudioIo: mic + speaker on dedicated threads, NOT a shared dispatcher — a
  capture loop blocks for the whole call, which is how BossTerm starved its IO
  dispatcher once before
- HostVoiceCallController: session.update handshake, mic streaming, playback,
  barge-in (drop queued agent audio when the user starts talking), and the same
  one-response.create-per-tool-round rule the browser path needed
- Tools run through the existing VoiceToolExecutor, so both surfaces expose one
  curated set; scope is this host's own tabs, defaulting to the focused one
- UI: status-strip segment (idle / calling / live / speaking / working / failed)
  plus a bar with a real mic level meter, Mute and End call
- macOS packaging: NSMicrophoneUsageDescription + the audio-input entitlement in
  both entitlement files — without them a packaged app is denied the mic

GuiVoiceToolExecutor's anchor tab became a provider: a share anchors on its own
tab, but an in-app call follows whichever tab is focused.

Tests drive the machine with a fake socket and fake audio (CI has no mixer):
session/audio config, base64 mic appends, mute stopping the stream, playback and
barge-in, a two-call round emitting exactly one follow-up turn, call-id dedupe,
no-key/disabled refusing before any socket opens, and teardown releasing both.
The API rejects the session with "Missing required parameter:
'session.audio.output.format.rate'" — the guide's WebSocket example shows the
output format as {type: audio/pcm} with no rate, but it is required on both
directions. Pinned in the test so it can't regress.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling (#345)

Read the whole diff. This is high-quality work — the security reasoning is unusually explicit (ephemeral-secret-only to the browser, key in a separate chmod-600 file, server-side canControl re-check on every write tool, scope-filtering list_tabs/get_active_tab locally rather than trusting the MCP handler, and keeping OpenAI's 5xx body host-side). The callToken handle so the share socket can't double as a standing tool RPC is a nice touch, and the doc comments explain why rather than what throughout. Coverage on the protocol/policy layer is real, including a Node harness that actually drives the WebRTC state machine.

Below: one bug I'm fairly confident is real, a few correctness/perf issues, then smaller notes.


1. The daemon's voice-status poller almost certainly never runs (bug)

DaemonShareServer.kt:709 calls watchVoiceStatus(def) during admit, but the viewer isn't registered until def.viewers.add(vc) at DaemonShareServer.kt:925 — ~200 lines later, after layoutFor, per-session snapshots and tap registration. The poll loop's guard is while (def.viewers.isNotEmpty()) (:1059).

For the first viewer of a share, def.viewers is empty when the coroutine body starts, so the loop exits immediately and finally { def.voiceWatchJob = null } runs. A new job only launches on the next admit, so a single-viewer daemon share — the common case — gets no polling at all. That defeats exactly the scenario the doc comment describes: adding a key (or flipping the switch) while a viewer is connected never reaches the browser, and revoking the feature mid-call leaves the Call button up while closeCalls() never fires.

Fix: move watchVoiceStatus(def) to just after the def.viewers.add(vc) block (~line 927), or restructure so it polls once before checking the guard.

Also if (def.voiceWatchJob?.isActive == true) return at :1056 is a check-then-assign outside mutex, so two concurrent admits can both launch a poller and one's finally nulls out the other's handle.

2. voiceEnd is a no-op, so tokens outlive their calls and the host is never told the call ended

MirrorShare.kt:414 and DaemonShareServer.kt:1100 both return on VoiceEnd. Consequences:

  • The callToken stays in liveCalls for CALL_TOKEN_TTL_MS = 6 hours after hangup. The only invalidation path is closeCalls(), which fires only when the master switch flips off. A viewer that hangs up — or is later downgraded to view-only — keeps a working handle for the read tools (read_scrollback, search_output, get_last_command) for the rest of the day. Low severity, since a viewer already sees the mirrored screen; but scrollback and search_output reach further back than what's mirrored, and it survives a control revocation.
  • onCallActivity(false) never fires in normal operation, so MirrorShare.notifyVoiceCall(false) ("The voice call … ended") is effectively dead code. The host is told remote hands arrived and never told they left — the asymmetry is what matters, since the start notification is the security signal.
  • Nothing invalidates tokens when the last viewer disconnects either.

Suggest VoiceCallService.closeCall(token), have VoiceEnd carry the token (or track token→connection), and drop a connection's tokens on WS close.

3. HostCallState mutation is a lost-update race across three threads

HostVoiceCallController does _state.value = _state.value.copy(...) in eight places (:120, 126, 132, 159, 163, 169, 213, 236), written from the boss-voice-capture thread (onLevel, ~25x/s), the JDK WebSocket listener thread (speaking, working), scope.launch coroutines (working = false), and the Compose UI thread (toggleMute).

Read-modify-write on a StateFlow isn't atomic, and with a 25 Hz writer running continuously the other fields will occasionally get clobbered — a Mute click that doesn't stick, or working = true erased by a level tick. MutableStateFlow.update { it.copy(...) } is a drop-in fix for all eight sites.

4. Mic level in the shared state recomposes all of TabbedTerminal at 25 Hz

TabbedTerminal.kt:2110 reads HostVoiceCall.state.collectAsState() inside the main terminal Box content lambda. HostCallState.level changes once per 1920-byte PCM chunk — every 40 ms — so that whole lambda re-executes ~25 times/second for the duration of a call, in the composable that owns the terminal rendering path. Given AGENTS.md's emphasis on the snapshot/lock-free rendering work, worth avoiding.

Either keep level out of HostCallState entirely (a separate StateFlow<Float> read only by LevelMeter in HostCallBar), or subscribe to a derived flow here — remember { HostVoiceCall.state.map { it.segmentState(...) }.distinctUntilChanged() }.collectAsState(Hidden). LevelMeter's animateFloatAsState on a 25 Hz target also means a continuously-running frame animation; probably fine, but it compounds.

5. JdkRealtimeTransport.connect blocks with .join()

RealtimeTransport.kt:78.buildAsync(...).join() inside a suspend fun, parking a Dispatchers.Default worker for up to 15 s. Same failure mode the (excellent) comment in VoiceAudioIo.kt:61 calls out for the capture loop. Use kotlinx.coroutines.future.await(), or at minimum withContext(Dispatchers.IO).

Same line: URI.create("$REALTIME_WS_URL?model=$model") doesn't encode model. It comes from a dropdown today, but TerminalSettings is a user-editable JSON file, so a stray character throws URISyntaxException out of connect instead of surfacing as a call failure.

6. Smaller notes

  • VoiceAudioIo.play() blocks the WebSocket listener thread. line.write back-pressure is the right idea, but it runs on the JDK WS callback thread, so a stalled speaker line stalls event dispatch — including input_audio_buffer.speech_started. Barge-in stops working exactly when playback is backed up. A small bounded queue plus a playback thread decouples them.
  • HostVoiceCall.start() leaks a collector per call. scope.launch { c.state.collect { ... } } (HostVoiceCall.kt:55) is never cancelled; each start adds another. Keep the Job and cancel it in end() / before the next start().
  • HostVoiceCallController.start() checks key before enabled (:79 vs :84), so a host with Boss Calling switched off and no key is told "Add an OpenAI API key" rather than "turned off". VoiceCallService.status() checks them the other way round.
  • Fully-qualified names inline. TabbedTerminal.kt:2110-2130, 2199-2207 and HostCallBar.kt:128-136 spell out ai.rever.bossterm.compose.voice.… / …share.CallSegmentState in expressions; MirrorShare.kt does it for NotificationService. TabbedTerminal already imports voice.segmentState, so it's inconsistent within one file.
  • M2/M3 mixing. HostCallBar uses androidx.compose.material3.Surface/Text while StatusStrip (rendered immediately above it, same column) and BossCallingSection use androidx.compose.material.
  • Viewer: a stray voiceToolResult bumps outputsOwed. viewer.js increments it before checking voice.pending[m.callId], so a result for an unknown/stale id can earn a spurious response.create later. Move the increment inside the if (voice.pending[m.callId]) branch.
  • VoiceAgentStorage._keyPresentFlow does file I/O at object-init — for TabbedTerminal that's a disk read on the Compose thread at first composition. Tiny, but easy to defer.
  • VoiceAvailabilityLine says "a viewer with control sees the Call BossTerm button", but the viewer shows it to everyone and gates on click via viewOnlyGate().

7. Test coverage

Good on the policy/protocol layer, and the viewer-harness.cjs scenario driving mint → SDP → data channel → tool round → kill switch is the kind of test most projects skip. Gaps I'd want before merge:

  • GuiVoiceToolExecutor has zero tests. DaemonVoiceToolExecutor has four, including both scope-escape cases — but the GUI executor is the one that reaches run_command, and it's where the load-bearing claims live: scope check applied to the locally-answered tools, toolNamePrefix applied when resolving handlers, and allowWriteTools = false actually suppressing run_command/send_input. Those are asserted only in doc comments right now.
  • No test for voiceEnd invalidating a token (see 🔍 Implement text search/find functionality (Ctrl+F) #2), CALL_TOKEN_TTL_MS expiry, or MAX_LIVE_CALLS eviction.
  • No test for MAX_IN_FLIGHT_TOOL_CALLS — the getAndUpdate CAS is subtle enough to deserve one.
  • Nothing covers watchVoiceStatus's lifecycle, which is how feat: Terminal rendering improvements + 4 high-priority features (#2, #3, #4, #5) #1 slipped through.

8. The PR description is out of date

The body describes only the share-viewer path, but the branch also adds the entire in-app host call: HostVoiceCall, HostVoiceCallController, RealtimeTransport, VoiceAudioIo, HostCallBar, a new CallSegmentState segment on StatusStrip, mic entitlements and NSMicrophoneUsageDescription. That's ~800 lines of production code, a new public API surface on StatusStrip, a new always-on pill in the main window, and a second place the raw API key is used (direct WS auth rather than an ephemeral secret — correct here since there's no untrusted party, but it's a distinct trust decision worth stating). HostVoiceCallControllerTest is likewise missing from the Testing section. Worth folding in before merge — it also means the "not yet verified end-to-end" caveat covers more surface than the body implies.


Nothing here is architectural; #1 and #2 are the ones I'd fix before merge, and #3/#4 are cheap. Nice feature.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling

Read the whole branch — the voice package, both share-server integrations, viewer.js, settings/packaging, and the new tests. This is unusually well-constructed for its size: the security model (host-only key, ephemeral ek_…, server-enforced canControl, scope-limited tool targets, generic errors to the remote caller, chmod-600 voice.json) is coherent and enforced in one place rather than duplicated per server. Findings below, roughly by severity — none undermine the design; most are concurrency/lifecycle details in the host-side path.

Scope note: the description covers only the share-viewer path, but the branch also ships a complete in-app "Call BossTerm" feature (HostVoiceCall, HostVoiceCallController, JdkRealtimeTransport, JavaSoundVoiceAudioIo, HostCallBar, the pill, macOS mic entitlements). That is the least-exercised part, and the part that opens the host’s own mic and grants the agent every tab in every window. Worth calling out so it gets tested as its own thing.

1. watchVoiceStatus almost certainly exits immediately for the first viewer

DaemonShareServer.kt:709 starts the poller, but the viewer is not registered until def.viewers.add(vc) at DaemonShareServer.kt:925 — ~200 lines later, past layoutFor, the per-session snapshots, and tap registration. The guard is while (def.viewers.isNotEmpty()), so the coroutine wakes on another thread, sees an empty list, and completes (clearing voiceWatchJob). On a single-viewer daemon share the status is then never re-broadcast — exactly the case the poller exists for (key added mid-share; feature revoked but the button stays up). Move watchVoiceStatus(def) after the successful add; the initial send(status()) at 708 can stay.

2. JdkRealtimeTransport.send is not safe for the traffic it carries

RealtimeTransport.kt:83. WebSocket.sendText throws IllegalStateException if a prior text send has not completed — the JDK contract is to wait on the returned future. It is fire-and-forget here, with two concurrent producers: the capture thread at ~25 input_audio_buffer.append frames/sec (HostVoiceCallController.kt:112) and coroutines sending function_call_output/response.create. runCatching swallows it and logs a class name, so the failure mode is silently dropped mic audio — presenting as "the agent cannot hear me" and very hard to trace. Serialize sends through a single-consumer queue, or chain on the previous future.

Same file: connect is suspend but ends in .join() (:78), blocking a Default worker up to 15s; and a fresh HttpClient is built per connect (:50) and never closed, leaking a selector thread + executor per call.

3. Playback blocks the socket callback thread, defeating barge-in

VoiceAudioIo.kt:89 — the comment acknowledges write() blocks for back-pressure, but the thread it blocks is the one JDK WebSocket uses for all event delivery (and onText already called ws.request(1)). Realtime streams audio faster than real time, so with a ~30 KB (~0.6s) line buffer the writer stays blocked for most of the agent turn. While blocked, input_audio_buffer.speech_started cannot be delivered, so flushPlayback() (HostVoiceCallController.kt:168) only runs after the audio it was meant to cancel is already written — barge-in, the reason flushPlayback exists, will not work. Function-call events are delayed the same way. A small playback queue drained by its own thread (mirroring the capture thread and its comment at VoiceAudioIo.kt:61) fixes both.

4. HostCallState updates race, and recompose at audio rate

HostVoiceCallController does read-modify-write _state.value = _state.value.copy(...) from three threads: capture (onLevel, ~25 Hz, :120), transport callback (speaking, :159/:163/:169), and UI/coroutines (toggleMute :132, working :213/:236). MutableStateFlow assignment is not a CAS, so a mute toggle landing mid-update is simply lost — _state.update { } throughout makes each atomic.

Perf: level lives in the same object TabbedTerminal.kt:2110 collects, so during a call the app’s main composable recomposes ~25x/sec. Only HostCallBar needs level; the pill needs phase/working/speaking. Split level into its own flow read inside LevelMeter, or feed the pill from map { it.segmentState(...) }.distinctUntilChanged().

5. Call tokens outlive the call by up to 6 hours

VoiceCallService.kt:290 sets a 6h TTL, and both servers ignore voiceEnd (MirrorShare.kt:414, DaemonShareServer.kt:1100). The token exists "so the share socket cannot be used as a standing tool RPC by a viewer that never started a call" — but after one legitimate call a viewer does get a standing tool RPC for six hours, hang-up or not, able to drive run_command with no OpenAI session in the loop and no host-visible call indicator. Invalidating that connection’s token on VoiceEnd and on disconnect closes it, and also fixes notifyVoiceCall(false): "The voice call … ended" is currently unreachable on the normal path, so the host is told a call started and never told it stopped.

6. Double-clicking Call tears down the call it just made

viewer.js:291. The voice.state !== "idle" guard runs before getUserMedia resolves, so two quick clicks make two mic streams and two voiceStart messages. The first mints; the second trips the 3s min-gap and returns rate_limited; the voiceError handler (viewer.js:2888) unconditionally calls endCall whenever state is not idle — killing the call that just connected. The first MediaStream is orphaned too (voice.mic overwritten at :298), so the browser mic indicator stays lit. Setting voice.state = "connecting" synchronously in the click handler (reverting on rejection) handles both. Also, rate_limited has no case in voiceErrorText (:395), so it renders as a bare "Could not start the call."

7. Test coverage: the GUI executor is the untested one

DaemonVoiceToolExecutor has three tests including scope rejection, but GuiVoiceToolExecutor has none — and it is the higher-risk of the pair: it carries run_command, resolves handlers through mcpConfig.toolNamePrefix, and enforces the scope check at GuiVoiceToolExecutor.kt:91-95. That check is the most security-relevant line in the feature and nothing exercises it. A registry-backed test mirroring DaemonVoiceToolExecutorTest would cover out-of-scope tab_id, list_tabs filtering, defaultTabId, and prefix resolution. Also uncovered: the MAX_IN_FLIGHT_TOOL_CALLS admission path (VoiceCallService.kt:158 — the getAndUpdate reasoning in that comment deserves a test) and token TTL/eviction in openCall.

Smaller notes

  • GuiVoiceToolExecutor.kt:91 — the "no tab available" throw also applies to list_tabs, so with in-scope tabs but no defaultTabId/anchorTabId the agent gets an error instead of the list. Resolve the target lazily for the two locally-answered tools.
  • DaemonShareServer.kt:1052 — the comment says "one stat of a small JSON file"; VoiceAgentStorage.load() does a full readText + JSON decode every 5s per share. A lastModified check would match the stated intent.
  • HostVoiceCall.kt:55scope.launch { c.state.collect { … } } never completes, so each call leaks a collector for the process lifetime. Cancel the previous job in start()/end().
  • HostVoiceCallController.kt:201 — deciding fatality by substring-matching a vendor error string is brittle; error.code is sturdier. Non-fatal errors are only logged, never surfaced, so a persistently failing call looks like silence.
  • RealtimeTransport.kt:77model is interpolated into the URL unencoded (fixed dropdown today, so not exploitable, but URLEncoder is free). :90-91sendClose followed immediately by abort() will usually abort before the close frame flushes.
  • The description says the SDP POST uses ?model=…; the code deliberately omits the query string (viewer.js:522-524).
  • Prompt injection deserves an explicit line in the docs even though it is inherent: the agent reads scrollback (attacker-influenceable — a fetched page, a log line, a repo file) and can then call run_command, with only an instruction asking it to confirm destructive commands verbally. A remote caller with control could already type commands, so the delta is bounded — but the in-app host path grants that surface across every tab in every window with no control gate at all. A read-only voice mode, or host-side confirmation for write tools, would be a cheap hedge.

Worth keeping as-is

  • Logging OpenAI’s error.message + x-request-id host-side while the viewer gets only the status code (VoiceSessionBroker.kt:90-103).
  • chmod-600 on the empty temp file before the key is written (VoiceAgentStorage.kt:99) — closes a window most implementations miss.
  • Flipping keyPresentFlow only on a successful write (:81), with the UI surfacing the failure.
  • Scope-checking the locally-answered list_tabs/get_active_tab rather than only the MCP dispatch path, in both executors.
  • Naming api.openai.com in CSP unconditionally, with the reasoning about CSP being baked at page load.

kshivang added 2 commits July 25, 2026 15:10
… tokens

Feature: clicking "Call BossTerm" with no key now opens a dialog to add one and
places the call on save, instead of erroring. The pill therefore shows whenever
the feature is on (new NeedsKey state) rather than hiding until the user finds
Settings.

Review fixes:

1. The daemon voice-status poller never ran. watchVoiceStatus() was armed during
   the admit preamble, ~200 lines before def.viewers.add(vc), and its guard is
   `viewers.isNotEmpty()` — so for a share's first (usually only) viewer the loop
   exited immediately and cleared its own handle. Now armed after registration.
   Its check-then-assign guard is also under a lock, so two concurrent admits
   can't launch two pollers with one nulling the other's handle.

2. voiceEnd was a no-op, so tokens outlived their calls by up to the 6h TTL — a
   viewer that hung up (or was later demoted to view-only) kept a working handle
   for the read tools, which reach further back than the mirrored screen. Added
   closeCall(token); VoiceEnd and viewer disconnect both retire the connection's
   token in either server, which also makes the "call ended" notification fire
   in normal operation instead of only when the master switch flips.

3. HostCallState was read-modify-written from four threads (25 Hz level ticks,
   the socket thread, tool coroutines, the UI). All eight sites use update {}.

4. The mic level no longer lives in the call state: it moved to its own flow, so
   a 25 Hz stream can't recompose the lambda that owns terminal rendering, which
   now subscribes to a derived distinctUntilChanged segment instead.

5. connect() no longer parks a dispatcher thread on .join() (moved to IO), and
   the model is URL-encoded — settings.json is user-editable, and a stray
   character threw URISyntaxException out of connect instead of failing the call.

Smaller: playback drains on its own thread through a bounded queue, so a stalled
speaker can't stall event dispatch and break barge-in exactly when audio is
backed up; HostVoiceCall cancels its mirror job instead of leaking a collector
per call; start() checks the switch before the key, matching status(); a stray
voiceToolResult no longer banks a spurious response.create; VoiceAgentStorage
probes the key off-thread rather than reading disk at class-init; the
availability line no longer claims only controllers see the button; imports
replace inline fully-qualified names.

Not changed: HostCallBar and StatusStrip are both androidx.compose.material3 —
they already match. BossCallingSection is M2 because SettingsComponents is.
…er the GUI executor

Closes the three findings from the latest PR review that the earlier round missed.

1. WebSocket.sendText was fire-and-forget with two concurrent producers (the
   capture thread at ~25 mic frames/sec and tool coroutines). The JDK contract is
   to wait on the returned future, so collisions threw IllegalStateException into
   a runCatching that logged only a class name — silently dropped mic audio,
   presenting as "the agent can't hear me". Sends now go through a single-consumer
   queue whose writer joins each future. Also: one shared HttpClient instead of
   one per connect (never closed before Java 21, so each call leaked a selector
   thread), and close() lets the close frame flush instead of aborting over it.

2. Two quick clicks on Call made two mic streams and two voiceStarts, and the
   second's rate_limited error tore down the call the first had established. The
   click now claims the call synchronously before awaiting the mic and reverts on
   denial; a stray stream is stopped rather than orphaned (the mic indicator used
   to stay lit); rate_limited no longer ends a live call and has real copy.

3. GuiVoiceToolExecutor had no tests despite being the half that carries
   run_command and enforces the scope gate. Seven added, centred on refusing an
   out-of-scope tab however it arrives, plus read-only/prefixed embedder configs.
   Also covered: the in-flight cap admitting exactly four, and token TTL expiry.

Smaller notes from the same review: list_tabs no longer errors when nothing is
focused (the target is resolved lazily, named targets still scope-checked); the
daemon's poller compares mtime+size instead of decoding voice.json every tick;
realtime errors are classified by code/param rather than by substring-matching
prose, and non-fatal ones surface in the call bar instead of only the log.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling — part 1 of 2 (what works, and the two issues worth fixing before the first real call)

I read the whole diff: both share servers, VoiceCallService/broker/executors, the in-app host path (HostVoiceCall*, RealtimeTransport, VoiceAudioIo), the viewer JS, and the tests.

This is well built. Things that stand out as genuinely good rather than merely present:

  • Key handling is right. Separate chmod-600 voice.json, atomic temp + ATOMIC_MOVE, permissions restricted while the temp is still empty, save() returning a boolean so the UI can't claim a key is set after a failed write, and the key never in a log line or a protocol message (with a test asserting the encoded voiceSession doesn't contain it).
  • Defence in depth on the tool surface: controller role at start and per write tool, an unguessable call token so the share socket can't be driven as a standing tool RPC, scope-checking before dispatch including the locally-answered list_tabs/get_active_tab, mint rate-limiting as an explicit spend limiter, an in-flight cap claimed with getAndUpdate rather than get-then-increment, and the master switch re-read per tool call so it's a real kill switch.
  • GuiVoiceToolExecutor inheriting the embedder's BossTermMcpConfig instead of fabricating one — the non-obvious correct call, or a deliberately read-only MCP surface would get run_command back via voice.
  • The tests earn their keep. The Node harness actually runs viewer.js through a full call (mint → SDP → data channel → tool round → revoke → hangup); HostVoiceCallControllerTest drives the state machine with a fake socket; the 5xx test asserts OpenAI's error text stays host-side. One-response.create-per-round is covered on both surfaces.

Your own caveat — no verified end-to-end call — is the real merge gate, and I'd keep it that way: the mint body, expires_after, semantic_vad, and the per-transport event names (output_audio_buffer.started/stopped for WebRTC vs response.output_audio.delta for WS — correctly chosen, but unverified) are all first-contact risks.


1. JdkRealtimeTransport.send() doesn't serialize sends — a dropped frame can wedge the in-app call

RealtimeTransport.kt:92. java.net.http.WebSocket requires the CompletableFuture from the previous send to complete before the next send is invoked; an overlapping sendText throws IllegalStateException. Three producers share this one method with no ordering:

  • the boss-voice-capture thread, once per ~40 ms chunk (HostVoiceCallController.startonChunk),
  • handleFunctionCall's coroutine, sending conversation.item.create,
  • maybeRequestResponse, sending response.create.

runCatching swallows the failure and send returns Unit, so the caller can't tell. Two consequences:

  • a dropped input_audio_buffer.append silently degrades what the model hears — at a 40 ms cadence one in-flight send is easy to outpace on any TLS/network hiccup, even without cross-thread races;
  • a dropped function_call_output hangs the call: handleFunctionCall proceeds to pendingCalls.remove / outputsOwed += 1 / maybeRequestResponse() as if it were delivered, so the follow-up turn fires while the model still waits on an output it never got.

Suggest one writer — a Channel<String> drained by a single coroutine, or chaining each send on the previous future — and having send report failure so a lost tool output can fail the round explicitly.

2. A voiceToolResult that never arrives leaves the viewer at "Working…" forever

viewer.js has a 15 s watchdog for connecting but nothing for a pending tool call. voice.pending[callId] is cleared only by a matching voiceToolResult, and until pendingCount hits 0 no response.create is sent — so a lost reply means "Working…" indefinitely, the agent mute, and no recovery but End call.

Not hypothetical on the GUI path: MirrorShare replies with vc.outbox.trySend(...), and BoundedViewerOutbox.trySend evicts the oldest queued frames under back-pressure (MirrorShare.kt:1062). A run_command that floods output is exactly when a queued voiceToolResult is the oldest thing in the queue. The daemon path already does this right — vc.outbox.sendControl(...), a guaranteed control lane.

Two independent fixes: host side, use the existing trySendWithoutEviction (or a control lane) for voice frames; viewer side, a per-call timeout that feeds an {"error":"timed out"} function_call_output back so the agent can say so out loud.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling — part 2 of 2

3. A stale defaultTabId breaks the entire tool surface, including list_tabs

GuiVoiceToolExecutor.kt:91 (and DaemonVoiceToolExecutor.kt:54) resolve tab_id ?: defaultTabId ?: anchorTabId() and then scope-check the result. MirrorShare.rememberVoiceTab validates scope at store time, but the tab can close afterwards. Once vc.voiceTabId names a closed tab, every tool fails with "Tab … is not part of this share" — including list_tabs and get_active_tab, which take no tab_id at all and don't need a target. The agent then has no way to discover a valid id, and the viewer only refreshes voiceTabId when the user taps another pane.

Falling back to anchorTabId() when defaultTabId is no longer in scope keeps the guarantee (the target is still scope-checked) while leaving the agent a way out. Resolving the target after the list_tabs/get_active_tab short-circuit would fix those two as well.

4. Double-tapping Call leaks a mic stream and kills the call it just placed

In voiceCallBtnEl.onclick the voice.state !== "idle" guard runs before getUserMedia, but the state only becomes "connecting" inside the .then. Two taps while the permission prompt is pending both pass:

  • voice.mic is overwritten by the second stream — the first one's tracks are never stop()ed, so the browser's mic-in-use indicator stays on for the life of the page;
  • two voiceStarts go out; the second trips MINT_MIN_GAP_MS (3 s) → rate_limitedvoiceErrorendCall(false), tearing down the call the first one just established.

A latch set synchronously in the handler (e.g. voice.state = "requesting") fixes both.


Smaller things

  • New pill for everyone, with no way to hide it. voiceCallEnabled defaults true, so segmentState returns NeedsKey on every existing install, and TabbedTerminal.kt:2140 adds callSegment != Hidden to the strip's visibility condition — the status strip now appears even for users who deliberately turned off both mcpShowStatusIndicator and sessionSharingShowIndicator. The repo's pattern is a per-indicator toggle; a voiceShowStatusIndicator (or simply not showing NeedsKey) would match it. Disabling the whole feature shouldn't be the only way to reclaim that corner.
  • Daemon shares never notify the host that a call started. DaemonShareServer builds VoiceCallService without onCallActivity, so the "the agent can read and run commands here" notification that MirrorShare.notifyVoiceCall sends never fires for a daemon share — precisely the case where nobody is watching the window. Similarly, MirrorShare.stop() mid-call clears viewers without closeCall, so the "call ended" notification is skipped.
  • JavaSoundVoiceAudioIo playback thread can exit before it starts. ensurePlayback()'s loop is while (running || playQueue.isNotEmpty()), and running is set only by startCapture. If play() ever runs first, the thread sees running == false and an empty queue and returns immediately — and since playback != null from then on, it is never restarted, so the agent is inaudible for the whole call with nothing logged. Today's ordering (connectsession.updatestartCapture) makes it unlikely, but setting running = true in ensurePlayback removes the silent failure mode.
  • pollVoiceStatus's finally guard is dead code. Inside finally the coroutine's job is still in the Completing state, so isActive is true and voiceWatchJob is never nulled. Harmless — the next watchVoiceStatus re-checks isActive — but the comment claims something that doesn't happen. (The try block is also indented a level too deep.)
  • MAX_LIVE_CALLS = 8 evicts the oldest token silently. When a 9th viewer dials, the earliest caller's tools start answering "No active call — start a call before using tools" while their audio keeps running, with no path back but hanging up and redialling. A VoiceError to that connection would at least make it diagnosable.
  • JdkRealtimeTransport.close() calls sendClose then abort() immediately, so the close frame may never flush; and onClosed can fire from both onClose and onError, so fail() can run twice.
  • Style: TabbedTerminal.kt writes ai.rever.bossterm.compose.voice.HostVoiceCall / ai.rever.bossterm.compose.share.CallSegmentState fully qualified about a dozen times while also importing segmentState from the same package — worth importing them for consistency with the rest of the file. HostCallBar.kt has unused fillMaxHeight/size imports.
  • Copy nit: the toggle description says the agent "can inspect this session and run commands" but never says the terminal contents are transmitted to OpenAI. For a shared session that seems worth one explicit clause.

Nothing here is architectural — 1–4 are all local fixes, and 1 and 2 are the two I'd want in before the first real end-to-end call, since both present as "the call froze" with no signal in the UI.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review: Boss Calling (PR #345)

Read through the whole diff. This is genuinely high-quality work — the KDoc explains why rather than what, the concurrency reasoning is careful (getAndUpdate for the in-flight cap, roundLock for the tool-round state machine, dedicated threads for the audio loops rather than dispatcher workers), the secret lives in its own chmod-600 file rather than settings.json, the remote caller gets a generic mint error while the detail and x-request-id stay host-side, and the CSP addition is pinned to one origin. Test coverage on the new units is good.

Below: things I think are real bugs, then design points, then nits (second comment). I did not build or run anything.


1. The minted call token is never attached to the connection, so hang-up/disconnect cleanup silently no-ops

VoiceCallService.openCall() generates the token inside handleStart and hands it straight to the reply. Neither server captures it:

  • MirrorShare.kt:412-418 — the reply lambda only encodes and sends.
  • DaemonShareServer.kt:1125-1128 — same.

vc.voiceCallToken is only ever populated from an inbound voiceToolCall (MirrorShare.kt:425, DaemonShareServer.kt:1136). So for any call where the agent never invokes a tool — a short "what's on screen?" answered from the snapshot, a call hung up during connect, a viewer whose tab crashes — both VoiceEnd and removeViewer call closeCall(null), which returns immediately. Consequences:

  • The token stays in liveCalls for the full CALL_TOKEN_TTL_MS (6 h). A viewer that hung up can keep driving the read tools (read_scrollback reaches further back than the mirrored screen does) for the rest of the day with a hand-rolled client. That is exactly the hole the closeCall KDoc says it closes.
  • onCallActivity(false) never fires, so the host gets the "a viewer started a voice call" notification with no matching "ended" — and on the GUI path that notification is the main signal that remote hands are on the machine.

Related, same lines: vc.voiceCallToken = msg.callToken ?: vc.voiceCallToken takes an unvalidated token off the wire. Sending one bogus voiceToolCall clobbers the tracked value, defeating the disconnect cleanup even in the normal case.

Suggested shape — capture on the way out, and only ever trust a token the host issued:

is ClientMessage.VoiceStart -> {
    rememberVoiceTab(vc, msg.activeTabId)
    voiceService.handleStart(msg, vc.canControl) { m ->
        if (m is ServerMessage.VoiceSession) vc.voiceCallToken = m.callToken
        vc.outbox.trySend(ShareProtocol.encodeServer(m))
    }
    return
}

and drop the assignment in the VoiceToolCall branch entirely.

2. HostVoiceCallController: ending during "Connecting…" leaks the microphone permanently

start() launches an unstored coroutine (HostVoiceCallController.kt:102-137) that does transport.connect() then audio.startCapture() then _state.update { phase = Live }. Nothing cancels it, and none of those steps re-check the state.

HostCallBar.kt:86 renders End call during Connecting, so this is reachable with one click during a slow connect:

  1. end() runs audio.stop() (no-op, capture hasn't started) and transport.close(), state goes Idle.
  2. HostVoiceCall.end() (HostVoiceCall.kt:73-80) then sets controller = null and cancels mirrorJob.
  3. The orphaned coroutine resumes and calls audio.startCapture() — the mic line opens, running = true, the boss-voice-capture thread starts.
  4. _state.update { it.copy(phase = Live) } — an unconditional copy from whatever phase it is in, including Idle/Error.

Since HostVoiceCall has dropped its only reference, nothing can ever call audio.stop() on that instance: the mic stays open and capturing for the rest of the process, with no UI showing a call. The same path is reachable via fail() from onClosed firing mid-connect.

The viewer JS already gets this right — viewer.js:306 re-checks if (voice.state === "idle") after getUserMedia resolves and stops the tracks. The Kotlin path needs the equivalent: store the Job, cancel it in end()/fail(), and guard the final transition on still being in Connecting.

HostVoiceCallControllerTest has ending releases the mic and the socket, but only for the already-Live case — a test that ends while connect is still suspended would pin this.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

3. GuiVoiceToolExecutor freezes disabledMcpTools at first call

private val server: Server by lazy { BossTermMcpServer(config = mcpConfig).createServer() } (GuiVoiceToolExecutor.kt:57). createServer() reads settingsManager.settings.value.disabledMcpTools once (BossTermMcpServer.kt:187); runtime changes are applied through applyDisabledSet(), which BossTermMcpManager only drives on its instance. Nothing ever calls it on this private one.

So the class KDoc's "createServer() already honors disabledMcpTools, so user tool-disables carry over to voice for free" holds only for disables that existed at the moment of the first voice call. After that, disabling run_command (via Settings or the manage_tools MCP tool) leaves it callable by voice until the app restarts — and conversely, a tool disabled at first call stays invisible after re-enabling. Given manage_tools exists precisely to toggle at runtime, that is a surprising divergence. Either subscribe the private server to the same settings flow, or rebuild it when disabledMcpTools changes.

4. Args outside the advertised schema pass straight through to the MCP handlers

GuiVoiceToolExecutor.execute copies every key except tab_id into effectiveArgs (GuiVoiceToolExecutor.kt:115-118). But argsJson is remote input (ClientMessage.VoiceToolCall), and the underlying MCP schemas are wider than the voice catalog advertises: run_command's real schema also accepts pane_id, panel, split_ratio, working_dir.

pane_id is safe — it resolves via state.findSession(tabId, explicitPaneId), so it cannot leave the scope-checked tab. panel: "new_tab" is the interesting one: on a ShareScope.TAB share (MirrorShare.kt:817 — exactly one tab), it makes run_command create a tab the share does not mirror and run there, returning output the viewer never sees on screen. A controller can already open tabs via ClientMessage.NewTab, so this is not an escalation, but it does undercut the "targets are hard-limited to the share's tabs/sessions" claim, and it is cheap to close: filter args to the keys declared in def.parameters.properties before dispatching.


Design / policy

One key + default-on spans two different trust boundaries. voiceCallEnabled gates both HostVoiceCall.available() (the local user talking to their own terminal, raw API key over a direct WebSocket) and VoiceCallService.status() (a remote share viewer voice-driving run_command). Both default on. So a user who stores a key purely to try "Call BossTerm" on their own machine has, in the same action, enabled voice-driven command execution for every holder of an existing control-share link, with no separate confirmation. The host notification on call start (MirrorShare.notifyVoiceCall) helps, but it is after the fact. Worth considering a second toggle for the share-viewer surface defaulting off, or a one-time host confirmation on the first remote call.

Indirect prompt injection. The agent reads scrollback — content an attacker can influence (curl output, a build log, a repo README) — and holds run_command / send_input. The only mitigation is the prose rule about destructive commands, which a model can be talked out of. Not a blocker for a feature of this kind, but worth saying in the settings copy; a host-side approval for the first write tool of a call (reusing the ShareRequestToast pattern) would be a proportionate guard.

The PR description doesn't cover the in-app call. The body describes only the share-viewer path, but roughly a third of the diff — HostVoiceCall, HostVoiceCallController, HostCallBar, RealtimeTransport, VoiceAudioIo, VoiceKeyDialog, and the StatusStrip/TabbedTerminal wiring — is a second surface with a materially different posture: the host's standard API key over wss://api.openai.com/v1/realtime, no ephemeral secret, no rate limit, no role check. Reviewers (and the changelog) should see that called out. The listed test counts are also stale: VoiceCallServiceTest has 14 tests rather than 9, and HostVoiceCallControllerTest / GuiVoiceToolExecutorTest are not mentioned at all.


Smaller things

  • DaemonVoiceToolExecutor.kt:54list_tabs requires a resolvable target: with no tab_id, no reported focus, and a null anchorSessionId(), it throws "No session available" instead of listing. The GUI executor deliberately handles this (GuiVoiceToolExecutor.kt:98-109, plus the list_tabs answers without a focused or anchor tab test). Worth mirroring so the agent's first orienting call cannot fail.
  • RealtimeTransport.kt:60outgoing is an unbounded LinkedBlockingQueue and the writer blocks on sendText(...).join() per frame, so a stalled socket accumulates base64 mic frames at ~25/s with no ceiling. A bounded queue that drops audio — the policy VoiceAudioIo.play already uses for playback — would be more consistent. Also, writer?.interrupt() in close() will not break a thread parked in CompletableFuture.join(), which is not interruptible.
  • VoiceCallService.kt:158-179 — if scope is already cancelled, scope.launch never runs the body, so finally { decrementAndGet() } does not fire and the in-flight slot leaks. Shutdown-only, but the increment could move inside the coroutine.
  • VoiceAudioIo.kt:89 — with no output device, ensurePlayback() re-attempts AudioSystem.getLine + open on every response.output_audio.delta. Caching the failure avoids a per-chunk hardware probe for the whole call.
  • VoiceCallService.kt:226 — the mint budget is per-VoiceCallService, i.e. per share, so a host with several shares open multiplies the ceiling. If the intent is "this is the spend limiter", a process-wide limiter matches that intent better.
  • VoiceAgentStorage.kt:68keyStamp is lastModified * 31 + length. On a filesystem with 1 s mtime granularity, replacing a key with one of the same length inside the same second is invisible to the daemon poller.
  • CSPmedia-src is not listed, so it falls back to default-src 'self'. The agent's audio arrives via srcObject (a MediaStream), which is not a fetch and so is not CSP-checked in current browsers — but since the end-to-end audio path is explicitly still unverified, worth confirming playback actually works under the served CSP once a working key is available.

The two I would want fixed before merge are #1 (the token never reaching the connection makes the call-token mechanism a no-op for its main case) and #2 (a permanently hot microphone is the kind of bug users report as spyware). Everything else is refinement.

kshivang added 2 commits July 25, 2026 15:25
…e-focus fallback

Closes the findings from the review of 24eed2a that weren't already covered.

1. A voiceToolResult could be dropped on the GUI path and wedge the call.
   MirrorShare replied through BoundedViewerOutbox.trySend, which evicts the
   OLDEST queued frames under back-pressure — and the moment that matters is a
   run_command flooding output, when the queued result is the oldest thing there.
   BoundedViewerOutbox now has a control lane that is never evicted and drains
   first (the counterpart of FrameOutbox.sendControl on the daemon side), and
   voice replies use it.

2. Viewer side of the same: a per-call timeout now feeds an {"error": …}
   function_call_output back so a lost reply can't pin the bar at "Working…"
   with the agent mute and no way out but End call. run_command gets a long
   leash (its own clamp is 600s); reads time out in 45s.

3. A stale defaultTabId broke the entire tool surface. The focused tab is
   validated when stored but can close afterwards, and treating that as a scope
   violation failed even list_tabs/get_active_tab — which take no tab_id and left
   the agent no way to discover a live id. A tab the model NAMES is still refused
   (that is the boundary); stale focus falls back to the anchor, which is in
   scope by construction. Both executors.

Smaller: the Call BossTerm indicator gets its own voiceShowStatusIndicator
toggle, so it doesn't appear for users who turned the other two indicators off;
daemon shares now send the same host-side call notification (the case where
nobody is watching a window); stopping a share mid-call retires its calls; the
playback thread sets `running` itself, so a play() before startCapture can't
leave the agent silently inaudible; pollVoiceStatus compares job identity
instead of isActive (which is always true inside finally, so the old guard never
fired); at capacity a new call is refused rather than silently evicting a live
one; TabbedTerminal imports instead of a dozen fully-qualified names; unused
imports dropped; the toggle copy now says terminal contents are sent to OpenAI.

Two earlier tests asserted the pre-fix strict behaviour and were updated: named
out-of-scope tabs are still refused, stale focus falls back — and either way the
foreign tab's data must never come back, which is what they now assert.
…live tool disables

Closes the review of bd7e4e6.

1. The call token never reached the connection. It was captured only from an
   INBOUND voiceToolCall, so a call where the agent used no tool left nothing to
   retire — the token stayed valid for its 6h TTL after hangup (read_scrollback
   reaches further back than the mirrored screen), and the host never got the
   "call ended" notification that pairs with the security-relevant "call
   started". Worse, the inbound value was unvalidated: one bogus token clobbered
   the tracked one and defeated disconnect cleanup entirely. Both servers now
   capture the token from the host's own VoiceSession reply and never trust one
   off the wire.

2. Ending during "Connecting…" could leave the microphone open for the life of
   the process. The connect coroutine was unstored, so after teardown it resumed,
   opened the mic, and set phase = Live — with the owner reference already
   dropped, nothing could stop that capture again. The job is now stored and
   cancelled by end()/fail(), every suspension point re-checks the phase, and a
   capture that started anyway is stopped. CancellationException is rethrown
   rather than reported as "Couldn't reach OpenAI".

3. GuiVoiceToolExecutor froze disabledMcpTools at the first call, so disabling
   run_command mid-session (which manage_tools exists to do) left it callable by
   voice until restart. The private server now has the current disabled set
   applied on each use.

4. Args wider than the advertised schema reached the MCP handlers — run_command's
   real schema also takes panel/working_dir, and panel:"new_tab" would run in a
   tab the share doesn't mirror. Args are filtered to the keys the voice catalog
   declares.

Smaller: the outgoing socket queue is bounded and drops oldest (it was unbounded
while the writer blocks per frame), with a timeout on each send so a wedged
socket can't park the writer forever; the daemon's list_tabs answers with no
resolvable target, like the GUI's; an in-flight slot no longer leaks if the scope
is already cancelled; the mint budget is process-wide (one key, one ceiling) and
injectable so tests don't rate-limit each other; a missing speaker is cached
instead of re-probed per audio chunk; the daemon's key-stamp cache re-reads
periodically, since a same-length replacement inside one second is invisible to
mtime+size.

New test: ending while connect is still suspended must never open the mic.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review — Boss Calling (#345) — part 1/2

Read the full diff. This is a genuinely well-built feature: the key never leaving the host, the ephemeral-secret split, scope-limiting tool targets to the share's tabs, sanitizing mint errors before they reach a remote guest, and the not_controller explicit-refusal choice are all the right calls — and the reasoning is written down where the next person will find it. The VoiceCallService seam that lets both share servers share one policy implementation is the right shape. 43 new tests is real coverage.

Five things I'd want addressed before merge; smaller notes and the testing/docs comments follow in part 2.


1. Live-call slots leak — the host never records the token it minted

MirrorShare.kt:414-419 and DaemonShareServer.kt:1147-1151: handleStart's reply lambda only serializes and sends. vc.voiceCallToken is assigned in exactly one place — from msg.callToken on an incoming voiceToolCall (MirrorShare.kt:427, DaemonShareServer.kt:1159).

So any call that ends before the agent's first tool call never retires its token: the caller hangs up (voiceEnd -> closeCall(null) -> no-op), reloads the page, or the socket drops during the SDP handshake. The token sits in liveCalls for the full CALL_TOKEN_TTL_MS of 6 hours. MAX_LIVE_CALLS = 8 and openCall() deliberately refuses rather than evicts, so ~8 aborted or tool-less calls wedge the share at too_many_calls for six hours — and MAX_MINTS_PER_WINDOW = 12 per 10 minutes means a viewer with a flaky network gets there without trying. Each refusal has already paid for a billed mint, since openCall() runs after broker.mint() returns.

Same root cause: onCallActivity(false) only fires when liveCalls empties, so the host's "call ended" notification never arrives for those calls either.

voiceService.handleStart(msg, vc.canControl) { m ->
    if (m is ServerMessage.VoiceSession) vc.voiceCallToken = m.callToken
    vc.outbox.sendControl(ShareProtocol.encodeServer(m))
}

voiceCallToken is already @Volatile, so the reply arriving on a coroutine rather than the socket read loop is fine.

2. BoundedViewerOutbox.sendControl is unbounded

MirrorShare.kt:1107-1116. The class exists to bound a stalled viewer's queue — trySend evicts past capacityChars/capacityFrames, trySendWithoutEviction refuses. The new lane honours neither and never closes on overflow:

synchronized(lock) {
    if (closed) return false
    controlFrames.addLast(text)   // no cap, no accounting, no close-on-overflow
}

The daemon counterpart the KDoc cites, FrameOutbox.sendControl, is explicitly bounded (1024 frames / 64 MiB, close() on overflow) with a comment about precisely this failure mode — the bound didn't come across with the lane.

It's reachable: the early-return error paths in VoiceCallService.handleToolCall (feature off, !isLiveCall, unknown tool name, !canControl) all reply before the MAX_IN_FLIGHT_TOOL_CALLS gate and cost nothing, and there's no inbound rate limit on the share socket. Any viewer holding a link — view-only included, since isLiveCall is checked before the role — can pump voiceToolCall frames while not reading its own socket and grow the deque without limit.

Also worth porting: drainTo now drains every queued control frame before a single output frame. FrameOutbox caps that at CONTROL_BURST = 64 for fairness.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling

I read the voice package (~4.3k lines), both servers' wiring, the voice half of viewer.js, the settings/UI surfaces, and the 18 new test files (~166 voice tests + the Node harness). I did not build or run the app; CI covers compilation.

Overall: unusually careful work for a feature this size. The things that normally go wrong in a realtime-audio + tool-calling bridge are addressed deliberately — single-consumer socket writer with a generation guard, two-lane outbound queue so a function_call_output can't be evicted by a mic chunk, dedicated threads for capture/playback rather than dispatcher workers, one tool round → exactly one response.create, scope-checked tab targeting on both executors with allowlist-filtered argument pass-through, and a real cross-language contract test instead of a "keep these in step" comment. The security posture (chmod-600 voice.json, 60s ephemeral secret, E2E-only mint, control role re-checked per write tool, CSP naming exactly one new origin) is the right shape.

1. The deadline ladder can't interrupt a CPU-bound handler — search_output can pin a thread and permanently leak an in-flight slot

The ladder is enforced with withTimeout/withTimeoutOrNull (GuiVoiceToolExecutor.kt:270, VoiceCallService.kt:458) and, in-app, by cancelling the tool job (HostVoiceCallController.kt:661). Coroutine cancellation is cooperative — none of those fire while the MCP handler does non-suspending CPU work.

search_output passes a model-supplied regex into Regex(pattern) and runs regex.findAll(lineText) over every scrollback line (BossTermMcpServer.kt:600,620), no suspension point in the loop. A nested-quantifier pattern against a wide line — (a+)+b, or the (\s*\w+)+error kind a model writes on its own — backtracks effectively forever.

Failure scenario: agent searches with such a pattern on a tab whose scrollback holds a long run of repeated characters (build log, progress bar, base64). The viewer's 120s backstop answers the model and the host logs "exceeded its budget; releasing the slot" — but inFlightToolCalls.decrementAndGet() sits in the finally of a coroutine that never returns, so the slot is never released. Four of those and every later voice tool on that share answers "Too many tool calls in flight" for the life of the process, with four dispatcher threads spinning. That's exactly the outage HostVoiceCallController.kt:155-162 says was fixed — the fix holds only for cancellable work.

search_output is guiOnly, so it's reachable by a remote viewer of a GUI share and by the in-app agent under prompt injection. Cheapest first:

  • Bound the input: clip/skip lines beyond some width and check a deadline between lines (the loop is already per-line). Covers the realistic accidental case.
  • For the pathological single line: match against a CharSequence wrapper whose charAt throws past a deadline, or run the scan on an interruptible thread.
  • Separately, treat "timeout fired but the block never returned" as its own condition, so the in-flight counter can't be leaked by a wedged handler — today its only release path is the handler completing.

A test with a handler that blocks uninterruptibly, asserting the slot is reclaimed, would pin this; today's timeout tests all use cancellable delays.

2. voiceCallShareEnabled defaulting on (your open policy question)

My vote: default it off (TerminalSettings.kt:1063). The asymmetry decides it — different callers (the owner vs. anyone holding a control link), non-comparable failure modes. Today, storing a key to try "Call BossTerm" simultaneously enables voice-driven run_command for every existing control-link holder, and the only signal is an after-the-fact toast plus the RemoteVoiceCalls pill. Off-by-default costs one switch flip and removes a whole class of "I didn't know that was on"; the in-app surface — the one the key was added for — keeps working.

I'd also endorse host approval for the first write tool of a remote call. The rule at VoiceInstructions.kt:47-52 is good framing and honestly labelled as not a mechanism. A one-shot per-call approval is proportionate, matches the existing join/control-request UX, and leaves the read tools alone.

3. viewer.js kept the seenCalls trim the Kotlin side fixed

viewer.js:550-553 trims the dedupe map by discarding every completed id, keeping only pending ones. HostVoiceCallController.kt:546-549 fixed exactly that shape ("a replayed call_id would re-execute its tool") by dropping the oldest completed id instead. The viewer is now the version with the documented bug.

Low severity (needs >400 calls in one session plus a duplicated/late call_id), but it's the cross-surface drift VoiceCrossLanguageContractTest exists to prevent, and that test doesn't cover it. Mirror the Kotlin trim, or keep an insertion-ordered id array beside the map.

4. Smaller notes

  • HostVoiceCallController.kt:104audio is eagerly constructed in the field initializer and immediately replaced by start(), so every controller builds a JavaSoundVoiceAudioIo that is never used or stopped. Harmless today, but it undercuts the "built per call" invariant newAudio's KDoc establishes; lateinit/nullable makes it structural.
  • JdkRealtimeTransport is documented as reusable and one window in that contract is still open: if close()'s 500ms join times out, writer feat: Terminal rendering improvements + 4 high-priority features (#2, #3, #4, #5) #1 can be parked in lanes.poll(200ms), wake after connect() bumped the generation, and take a frame — it reads socket (now the new one) before rechecking keepWriting. Two concurrent sendTexts is the collision the counter was added for. Unreachable in production (one transport per call), so either narrow the reuse claim or re-check the generation after the poll returns.
  • HostVoiceCall.kt:57-69created is a captured local written on the caller's thread and read from the transport's callback thread via onTerminal. Correctly ordered in practice; a @Volatile field or AtomicReference makes the visibility explicit rather than incidental.

5. Comment density

Raised once. A large share of the comments are archaeology — what the code used to do, which test stayed green, how many rounds it took ("This was added as a SECOND response.created branch, which when never reaches"; "eight tests stayed green while the fix did nothing"). The invariants are valuable and should stay; the story of how they were reached is what git history is for. At this density it's materially harder to review and edit — a reader decides per paragraph whether they're being told a rule or a story. AGENTS.md asks for density matching the surrounding code, and this is several times the rest of the codebase. A pass keeping the "why this must be true" sentence and dropping the "here's what happened when it wasn't" one would help.

6. Test coverage

Strong: the ladder asserted as a ladder, the transport lifecycle covered through an injected socket opener, the audio loop through injected lines (CI's missing mixer isn't a hole), a harness that runs the viewer, and source-level cross-language contracts with a clear rationale for why grep is right there. only one of many concurrent clears may win, validated against a deliberately broken implementation first, is the right instinct.

Gaps I'd close:

  1. The wedged-handler case in §1 — nothing asserts the in-flight slot survives a non-cancellable tool.
  2. voiceCallTokenMatches is unit-tested as a pure function, but nothing asserts either server calls it. Its own KDoc says it lives outside VoiceCallService "which is exactly how it could be lost in a refactor" — a test driving MirrorShare's / DaemonShareServer's handler with a forged or foreign token and expecting stale_call is the missing half.
  3. The viewer's seenCalls trim (§3) — the harness can drive 400+ calls cheaply.

Nothing here challenges the design. §1 is the one I'd fix before merge, §2 is a product call I'd resolve toward the conservative default, §3/§4 are cheap. Given no real end-to-end call has succeeded yet (account-side 500s), I'd also want the audio path, live tool calls and the mid-call failure toasts exercised against a working key first — the state machine is well-tested against fakes, but the event-family split between surfaces (output_audio_buffer.* vs response.output_audio.*) is the kind of thing only a real call validates.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling (voice)

I read the new compose/voice package, the two share-server integrations, the viewer's voice half, and the test suite. This is a large, unusually careful piece of work: the security boundary is drawn in one place (VoiceCallService), the tool surface is one curated catalog shared by both call surfaces, audio genuinely never touches the tunnel, and the injected seams (transport, audio IO, clock, key loader) mean the two protocol state machines are exercised without a socket or a mixer. ~4k lines of tests including a Node harness for viewer.js and cross-language contract tests is the right investment for a feature with a mirrored implementation.

Below: two things I would resolve before merge, then substantive comments, then nits.


Worth deciding before merge

1. voiceCallShareEnabled still defaults to true. The KDoc (TerminalSettings.kt:1060) already records the recommendation to default it off and calls it "a product call rather than a code one" — which flags that the call has not actually been made yet. It matters more than a normal default because of the asymmetry the code itself documents at VoiceCallService.MAX_CALL_DURATION_MS: the host cannot end a remote call's audio session, only its tools. So a user who stores a key to try "Call BossTerm" in their own window has, in the same action, given every control-share link holder the ability to start a billed OpenAI session that runs to the server's ceiling regardless of anything the host does afterwards. voiceCallEnabled = true is well argued (the key is the opt-in for the in-app surface); the remote surface being the same switch-flip is the part that does not follow. Suggest voiceCallShareEnabled = false, or a one-time host confirm on the first remote call per share.

2. No host-side gate on the first write tool. voiceAgentRules is candid that "a host-side approval on the first write tool is the real answer" and that the injection rule "is not a mechanism". I agree with the framing — and note this is not a privilege escalation for the share path, since a controller can already type — but it is an unattended one, driven by text the agent reads out of scrollback that any program the terminal renders can author. VoiceContextSnapshot sanitizes titles/cwds precisely because they are attacker-influenceable; tool output is the much larger surface and has only the prompt line. A first-write confirm (host toast, or a per-call "allow writes" acknowledgement) would close it. If that is out of scope here, a tracking issue would be good, because the RemoteVoiceCalls indicator is currently doing all of the work that comment describes.


Substantive

3. ServerMessage.VoiceStatus.reason KDoc is now wrongShareProtocol.kt:236 says ("disabled" | "no_key"), but status() also emits insecure_transport and not_controller, SELF_DESCRIBING_REASONS keys off those exact strings, and updateVoiceBar switches on all four. The sibling VoiceError list right below it is exhaustively documented and pinned by VoiceCrossLanguageContractTest; this one is neither. Cheap fix, and in a PR this documentation-heavy it is the kind of drift that erodes trust in the rest of the prose.

4. The tool-round state machine is implemented twice, and only its constants are pinned. pendingCalls / seenCalls / responseOpen / outputsOwed / the watchdog / the dedupe trim exist in HostVoiceCallController and again in viewer.js — the same ~150 lines of subtle "answer exactly once, request exactly one follow-up turn" logic in two languages. VoiceCrossLanguageContractTest pins the ladder values, the error vocabulary, the captions and the dedupe limit, which is real coverage, but the PR's own comments record at least two cases where one side was fixed and the mirror was not (the seenCalls trim; send_input's unconditional ellipsis). Since the Node harness already exists, driving one scripted event sequence (response.created → two function_calls → one late result, one timeout → response.done) through both implementations and asserting the same emitted frames would catch the next divergence structurally rather than by grep.

5. connect-src https://api.openai.com is unconditional (index.html:19), so every viewer can reach OpenAI even on a share where Boss Calling is off or keyless. Narrowing it the way __BOSSTERM_WS_ORIGINS__ is interpolated would be tighter — though the counterargument is real: the CSP is fixed at page load and availability changes at runtime (a key added while a viewer has the page open), so gating it would break that. Fine to keep as-is; worth a one-line comment in index.html saying why it is unconditional, so the next reader does not "fix" it.

6. Comment-to-code ratio, and changelog comments specifically. This is my main maintainability concern. Large stretches read as a narrative of bugs fixed during development rather than a description of the code — VoiceCallService.kt:254-268, HostVoiceCallController.kt:444-453 ("This was added as a SECOND response.created branch… eight tests stayed green while the fix did nothing"), RealtimeTransport.kt:199-215, VoiceAudioIo.kt:326-330. It is genuinely informative on first read, but:

  • it is a lot of prose to keep true, and it is already drifting. viewer.js:3076-3079 explains that clicking Call as a view-only viewer meant "nothing sent the request, so they granted microphone access, got nothing" — but voiceCallBtnEl.onclick now short-circuits through viewOnlyGate() (line 320) before the mic prompt and before voiceStart, so that not_controller branch is a defensive race fallback, not the path the comment describes.
  • a future reader cannot distinguish "this is the invariant" from "this used to be broken".

Suggest keeping the invariant and the why, and moving the "it used to do X" archaeology to commit messages — git already holds it, and the commit is where it is discoverable without being load-bearing.

7. iOS remote-audio playback. voiceAudioEl is created and its srcObject assigned from pc.ontrack, i.e. outside the click gesture, with autoplay and no playsinline and no .play().catch(...). Given the viewer explicitly targets phones, worth testing on iOS Safari; el.play().catch(() => toast("Tap to hear Boss")) is cheap insurance even if it turns out the getUserMedia grant is sufficient.

8. Minor UX gap on the control upgrade. On not_controller the viewer sends requestControl and calls endCall(false), which stops the mic. If the host then approves, nothing resumes the call — but voiceErrorText says "asking the host now", which reads as though it will. Consider re-arming the call attempt when a control granted arrives that was triggered by a Call click (the pendingUpstreamControlTab pattern already does something similar).


Nits

  • VoiceCrossLanguageContractTest.kt:151 constructs a real HostVoiceCallController, which defaults to JdkRealtimeTransport() and the JavaSoundVoiceAudioIo factory, just to call describeTool. Nothing connects so it is harmless, but describeTool does not need the controller — a top-level function would keep a caption test from ever being able to touch a socket or a mixer class.
  • VoiceAgentStorage.keyStamp() and fileStamp() have identical bodies; keyStamp() can be fileStamp(defaultFile()).
  • GuiVoiceToolExecutor.tools() destructures a local named wrapper that shadows the field of the same name (line 132). Legal, but it reads as if the field were being reassigned.
  • VoiceCallService.refundMint matches reservations by timestamp value and documents the imprecision; a monotonic reservation id in the deque would make it exact at the same cost.
  • VoiceCallService.openCall() starts the sweeper before openCallLocked(), so a refusal at capacity still spins one up for a tick. Self-correcting, just noting it.

Performance / security notes (no action needed)

  • status()/keyPresent() running on the WS receive loop, and on the daemon becoming a settings.json read+parse under the same monitor the 5s poller holds, is documented honestly in the code and bounded by MAX_MINTS_PER_WINDOW. It is the first place I would expect to feel pressure if voice traffic ever gets chatty.
  • Good calls that are easy to miss and worth naming: the 60s expires_after on the minted secret; the StoredVoiceConfig.toString() override; chmod-600 applied to the temp file before the key bytes are written; declaredParameters allowlisting so panel: "new_tab" cannot escape the share scope; per-connection call-token binding extracted to voiceCallTokenMatches so it is unit-testable outside the service; and the sendControl lane so a run_command output flood cannot evict a queued tool result.

I did not build or run this (per AGENTS.md), so the above is from reading only.

Reported from a real call: the agent reacts to too much background noise.

The cause is that both surfaces shipped OpenAI's `semantic_vad` with no options,
and semantic VAD deliberately exposes no activation threshold — it is a model
deciding whether you have finished a thought. That is genuinely better at not
cutting you off mid-sentence, and it offers nothing at all for a room with a fan
in it. There was no knob to turn.

`server_vad` is the mode with `threshold` (0.0-1.0) plus `silence_duration_ms`,
so the tunable levels use it:

  Noisy room   threshold 0.80, 900ms silence
  Normal       threshold 0.60, 700ms   <- default
  Quiet room   threshold 0.35, 400ms
  Automatic    semantic_vad, unchanged behaviour

The default is now "Normal" rather than Automatic, and 0.60 sits slightly above
OpenAI's own 0.5 default — deliberately, since the reported problem was
over-triggering. Automatic is still one click away for anyone who prefers the
model's judgement about turn-ends in a quiet room.

One definition for both surfaces (VoiceTurnDetection), because the mint body and
session.update are exactly the pair that has drifted before; both existing tests
that pinned "semantic_vad" now assert the two carry the SAME object.

698 tests (+6). Note the thresholds themselves are still unverified against a
live call — they are reasoned from the API's documented ranges, so they may want
tuning once you have tried them.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling (PR #345)

Read the whole diff (68 files, ~11.4k added), with most attention on voice/, the two share servers' voice paths, and the viewer's call state machine. I did not build or run the tests (no Gradle network in this environment), so everything below comes from reading.

Short version: this is unusually careful work. The two-surface split is honest about being two surfaces, the security boundaries are enforced in one place and unit-tested there, and the browser half is actually tested — which almost never happens. The things I'd want changed before merge are mostly policy, not code.


1. The remote surface still has no host-side gate on the first write tool

This is the one I'd push on. VoiceInstructions.kt says it out loud:

One line is not a mechanism (a host-side approval on the first write tool is the real answer), but it is strictly better than telling the model nothing

Agreed on both halves — and the mechanism is still missing. Meanwhile voiceCallShareEnabled defaults to true, and its KDoc already records the recommendation to default it OFF as "a product call rather than a code one". Taken together, the shipped behaviour is: a host who stores a key to try the in-app pill has silently armed voice-driven run_command for anyone holding a control-share link, with prompt-injection defence consisting of one sentence in the system prompt.

Two concrete asks:

  • Default voiceCallShareEnabled = false. The two switches exist precisely to separate the trust boundaries; defaulting the remote one on gives back half of what the split bought.
  • Gate the first write tool of a remote call behind the approval flow that already exists (SessionShareManager.awaitApproval — the join / control-upgrade path in MirrorShare is the same shape). Once per call is enough; reads can stay ungated.

VoiceContextSnapshot shows exactly the right instinct applied to the system prompt surface. Tool output is the much larger surface and currently has instruction-level framing only.

2. HostVoiceCallController.fail() defers teardown but reads the fields late

scope.launch(Dispatchers.IO) {
    runCatching { audio.stop() }        // field read at execution time
    runCatching { transport.close() }
    runCatching { executor.dispose() }
}

end() tears down synchronously; fail() is the one path that doesn't. If start() runs on the same controller before that coroutine gets a slot, the old teardown stops the new call's capture line, closes the reusable transport out from under it, and detaches the new MCP server registration.

Unreachable today, because HostVoiceCall.start() always constructs a fresh controller — but start() is written as fully re-entrant (audio = newAudio(), round state cleared, terminalReported.set(false)), and every test constructs a fresh controller, so nothing pins the single-use contract. Pick one:

  • capture audio / transport / executor into locals and guard the body on a call-generation counter, or
  • latch it single-use the way JavaSoundVoiceAudioIo latches disposed — the precedent this very file cites approvingly for newAudio.

A cheaper variant of the same shape: HostVoiceCall.end() defers ending.end() (which closes the TargetDataLine) to IO while start() opens a new one synchronously. Hang up → immediately redial → on any mixer that won't hand out two capture lines the user sees "No microphone available". Awaiting the previous teardown in start() covers both.

3. msg.name is the one remote string that isn't bounded

handleToolCall bounds callId and argsJson, then:

reply(toolError(msg.callId, "Unknown tool: ${msg.name}"))

A viewer sending a multi-megabyte name gets it echoed verbatim into a control frame. sendControl's ceiling contains it (the connection is closed rather than grown), so this is not a memory hole — but the failure lands as a disconnect instead of as the tool error it should be, and msg.name.take(…) is a one-liner. "Tab $named is not part of this share" has the same shape, bounded at 32 KiB by MAX_ARGS_CHARS.

4. Things I'd state plainly in the PR body / settings UI rather than change

All of these are documented somewhere in the code, which is better than most PRs manage — but they are the facts a host needs and the PR description doesn't say them.

  • The tool bridge is an RPC, not an agent transcript. Once a call is minted, that connection can send arbitrary voiceToolCall frames for up to MAX_CALL_DURATION_MS; the host cannot distinguish "the model asked for this" from "the client skipped the model entirely". Not an escalation over what a controller can already do with Inputexcept that read_scrollback reaches history the mirror never carried.
  • The kill switch revokes tools, not audio or billing. MAX_CALL_DURATION_MS, closeCalls() and voiceCallEnabled all retire the token; the browser↔OpenAI session keeps running and keeps billing for an uncooperative client. VoiceCallService's KDoc is exact about this; the settings toggle should be too. "Turn Boss Calling off" reads like "stop the spend", and it isn't.
  • The mint budget is a cross-share denial, not just a spend cap: one redial-looping viewer on share A locks out share B for the window, both seeing an undifferentiated rate_limited. The KDoc names the fix (a per-connection sub-budget under the global one) — worth doing as follow-up.
  • CSP is now permanently widened. connect-src … https://api.openai.com ships to every viewer, including hosts with voice off. respondShareViewerIndex already templates per request, so gating that origin on voiceCallShareEnabled would keep the policy honest.

5. Performance — two small ones

  • MirrorShare's tickerFlow(VOICE_REVOCATION_POLL_MS) runs every 5 s for the life of every share, feature on or off, key or no key, forcing a full voice.json read+parse every 12th tick via STAMP_TRUST_TICKS. Individually trivial; it's N shares × forever for a path that cannot change anything while voiceCallEnabled is false. Gate the ticker on the switch.
  • Each tool call goes through executor.tools() twice — once in VoiceCallService.handleToolCall, once at the top of GuiVoiceToolExecutor.execute — so two @Synchronized surface() passes, each taking toolsLock and copying the disabled set. Harmless at 4 in flight, but execute's comment claims "ONE rebuild per call", which is true of execute and not of the round.

6. What's genuinely well done

Naming specifics, because these are the parts I'd want other reviewers to copy:

  • OutgoingLanes — the guaranteed/evictable split, and the writer loop treating a failed sendText as terminal for the socket rather than a lost frame. The orTimeout reasoning (the JDK won't accept another text send while one is pending, so there is no recovery from here) is exactly the bug that normally ships as a permanently mute call with nothing in the log.
  • VoiceToolTimeouts — one ladder spanning three processes and two languages, with a test asserting the ordering. The right answer to "prose at four separate sites".
  • VoiceContextSnapshot — sanitising terminal-controlled titles and cwds before they enter the system prompt, not after. Right threat model, and U+2028/2029 (outside isISOControl, outside Java's \s) is a real catch.
  • declaredParameters allowlisting, so run_command's wider MCP schema (panel: "new_tab") can't be reached from voice — and applied identically in both executors.
  • Key handlingStoredVoiceConfig.toString() overridden structurally rather than by convention, and chmod 600 applied to the temp file while it is still empty, before the key bytes land.
  • VoiceCrossLanguageContractTest + the Node harness driving the real shipped viewer.js against a fake RTCPeerConnection, fake clock and fake rAF queue, with requireOrSkipNode() hard-failing in CI. The viewer's ~500-line call lifecycle is the half most PRs leave untested entirely.

7. One style note

Several of these files run past 50% comments, and a large share of that is incident narrative rather than invariant — "this used to…", "eight tests over this state machine stayed green while the fix did nothing", "an earlier version put a redundant literal beside this and claimed ESC was outside the range". The invariants are worth every line and I'd keep all of them. The archaeology is what git log is for, and it's the part most likely to drift into being wrong — at which point it misleads with the same authority as the parts that are right.

AGENTS.md asks for code that matches the surrounding comment density; voice/ is now its own dialect within the repo. A pass that keeps "why this must hold" and drops "what broke last time" would cut real volume without losing anything load-bearing.


Nothing here blocks except item 1, and item 1 is a default change plus a reuse of an approval flow that already exists. Nice work.

… slot

Review round 39 (three queued reviews).

The in-flight cap trusted the handler to finish. withTimeout and job
cancellation are COOPERATIVE — they land at a suspension point, and an MCP
handler doing non-suspending CPU work has none. search_output hands a
model-supplied regex to Regex() and scans per line, so a nested quantifier
against a long line backtracks effectively forever: the viewer's backstop
answers the model, the host logs "releasing the slot", and the `finally` that
actually releases it never runs. Four of those and every later voice tool on the
share answers "Too many tool calls in flight" for the life of the process —
precisely the outage the cap was added to prevent, caused by the cap.

Reachable by a remote viewer of a GUI share, and by the in-app agent under
prompt injection.

Two layers:

  - Capacity no longer depends on the handler returning. Both surfaces reclaim
    the slot on a deadline of their own; whoever gets there first wins a CAS, so
    it is released exactly once. The thread stays pinned — nothing outside can
    fix that — but the share keeps working.
  - The accidental case is bounded at its source: search_output checks a
    wall-clock budget per line AND inside the match loop, clips lines past 8k
    chars (a 200k line is a progress bar or base64, and exactly the input that
    makes backtracking explode), and tells the agent WHY it stopped so it
    rewrites the pattern instead of retrying it.

Every other timeout test used a cancellable delay, which is why this was
invisible. The new one wedges a handler on a latch that is never released and
asserts the slot comes back anyway. The share path's tool budget is now injected
for it, matching HostVoiceCallController.toolTimeoutMs and for the same reason.

699 tests. Verified against the unfixed code — and my first attempt at that
verification passed only because I had mangled the file into a compile error and
was reading stale results.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling (voice-call the session's AI agent)

I read the whole diff: the two VoiceCallService/HostVoiceCallController state machines, both executors, the transport/audio layer, the share-protocol and server wiring on both surfaces, the viewer JS, and the test suite.

Overall: this is unusually careful work. The security posture is coherent and the layering is right — one VoiceToolCatalog feeding two executors, one voiceAgentRules, one VoiceTurnDetection, one VoiceToolTimeouts ladder that's actually asserted in a test. The things I went looking for as likely bugs were already closed: token binding is per-connection (voiceCallTokenMatches, not "live somewhere on the share"), the #k E2E gate on minting is a required argument rather than a defaulted one, declaredParameters() narrows the model's args to the catalog so panel: "new_tab" can't escape the share scope, expires_after: 60s on the ephemeral secret, and the in-flight-slot reclaim exists precisely because withTimeout can't interrupt a backtracking regex. The SEARCH_SCAN_BUDGET_MS + SEARCH_MAX_LINE_CHARS addition to search_output is the right fix at the source.

So most of my feedback is about policy defaults and merge readiness, not implementation.


Blocking before merge

1. No verified end-to-end call. You flag this yourself, and it's the single biggest risk. VoiceCallServiceTest asserts the mint body shape (expires_after, output_modalities, turn_detection) — but against your reading of the API, not against the API. The same is true of sessionUpdate()'s audio.output.format.rate, the two divergent "agent is speaking" event families (output_audio_buffer.* for WebRTC vs response.output_audio.* for WebSocket), and whether session.update accepts model when it's already bound by the URL query. Every one of those fails at runtime, once, in a way no unit test here can catch. Please don't merge until one real call has been placed on each surface.

2. voiceCallShareEnabled should default falseTerminalSettings.kt:1063. You raise this as an open question; my vote is unambiguous. As shipped, a user who stores a key to try the in-app "Call BossTerm" pill has also granted voice-driven run_command to anyone already holding a control-share link, with no separate action and no consent step. The host notification is after-the-fact, and RemoteVoiceCalls correctly notes the toast is a no-op off macOS. The asymmetry between the two surfaces is the whole justification for having two flags — an "in-app only until you say otherwise" default costs one toggle for the remote case and removes a class of surprise entirely.

3. voiceRunInFocusedPane defaults true (TerminalSettings.kt:1075) — reconsider in light of injection. The UX argument in the KDoc is genuinely good, but it composes badly with the injection surface: the agent reads scrollback an attacker can influence (curl output, a build log, a README) while holding run_command, and this setting routes the resulting command into the user's own live shell rather than a scratch split. idleFocusedPaneId() only rules out a command already RUNNING — it can't tell that someone is mid-thought at their prompt. The builtInRules anti-injection paragraph is a real improvement over nothing, but as the comment there admits, one sentence is not a mechanism. Either default this off, or land the host-side approval-on-first-write-tool you describe. (I'd take the approval — it's the proportionate guard for both this and #2.)


Bugs

4. voice.seenCalls / voice.pending / voice.timers are plain objects, so prototype keys read as truthyviewer.js:549, :566, :602, :3091. A call_id of constructor, toString, valueOf, or __proto__ makes voice.seenCalls[callId] truthy on the first sighting, so voiceHandleFunctionCall silently drops the call and the round hangs until the 120s watchdog. Realtime call ids are call_… today so this isn't reachable in practice, but it's a one-line fix: Object.create(null) for all three, in the initializer and everywhere they're reset (endCall, the Call click handler). The Kotlin mirror uses LinkedHashSet/HashSet and doesn't have the problem — exactly the kind of asymmetry VoiceCrossLanguageContractTest exists to catch.

5. The slot-reclaim watchdogs are never cancelled on successVoiceCallService.kt:500 and HostVoiceCallController.kt:616. Both are launch { delay(budget + grace); if (CAS) release }, and neither is cancelled when the tool completes normally. It's harmless (the AtomicBoolean CAS makes it a no-op) but it parks one coroutine per tool call for the full budget — 615s + 5s for every run_command. A long call that runs twenty commands keeps twenty timers alive for ten minutes each. job.invokeOnCompletion { reclaim.cancel() } costs a line and makes the resource story match the intent.

6. Blocking file IO on Dispatchers.DefaultMirrorShare.kt:143 + :222. The 5s tickerFlow drives keyOnDisk.get() on coro (Dispatchers.Default, line 86), which stats voice.json every tick and does a full read+parse every 12th (STAMP_TRUST_TICKS). It's tiny, but the codebase's own reasoning cuts against it: VoiceAgentStorage spawns a probe thread specifically to keep this off the Compose thread, and BossCallingSection wraps the identical call in withContext(Dispatchers.IO). Making the MirrorShare tick consistent closes the inconsistency for free.


Design / operability notes

7. The process-wide mint budget is a cross-share denial, not just a spend cap. MINT_MIN_GAP_MS/MAX_MINTS_PER_WINDOW are shared across every share in the process — deliberate and documented at VoiceCallService.kt:837, and I agree with the reasoning (one key, one ceiling). But the consequence is that one viewer redial-looping on share A locks out a legitimate caller on share B for ten minutes, and both see an identical rate_limited with nothing to distinguish them. The per-connection sub-budget you suggest in that same comment would keep the ceiling while making starvation local to whoever caused it; worth doing before this sees more than one concurrent share in the wild.

8. MAX_CALL_DURATION_MS is honest about what it can't do, and the UI should be equally prominent. The KDoc is admirably clear that the host is not a party to the browser↔OpenAI audio session, so nothing host-side can hang up a client that ignores voiceStatus — the real bound is MAX_MINTS_PER_WINDOW × session length. The Sharing tooltip in StatusStrip.kt says this well. In the voiceCallShareEnabled settings description it's the fourth clause of a long paragraph, and it's the one fact a host most needs before flipping that switch.


Test coverage

Strong — ~180 new tests, and the ones that matter are the ones I'd have asked for: the call-token gate against forged/retired/expired tokens, the in-flight cap admitting exactly four, the 5xx never forwarding OpenAI's error text, scope rejection for a named foreign tab across every tool, POSIX perms on the key file, and the ladder invariant in VoiceToolTimeoutsTest.

Two notes:

  • The PR body undersells the viewer coverage. It says ShareViewerScriptTest "covers the new viewer code loading and initializing", but viewer-harness.cjs actually drives the full call lifecycle — mute, barge-in, tool round-trip, a duplicate voiceToolResult, mid-call revocation ending the call, the long-tool idle exemption, and socket-down teardown. Worth correcting, because a reviewer who trusts the body will assume the JS side is unverified. (The fakeSetInterval fix — intervals had been aliased to setTimeout, so they fired at most once — is a genuinely good catch that retroactively validates other scenarios too.)
  • Gap: nothing exercises clampToolResult's 40 KiB output against BoundedViewerOutbox.CONTROL_CAPACITY_CHARS (8 MiB). ~200 clamped results would close the connection. That's the intended behaviour, but the interaction between the two bounds isn't pinned anywhere and they were chosen independently.

Style

One honest maintainability observation. The comment-to-code ratio here is very high, and a large share of it is archaeology — "this used to be X, which was a bug, here's what it did." VoiceCallService.kt is 882 lines of which roughly half are prose; HostVoiceCallController.start() has more comment than code.

The invariant comments are excellent and should stay — "peek the budget before retirePreviousCall, because a rate-limited redial used to destroy the caller's live bridge" tells the next editor something the code cannot. But the pure changelog entries ("this was added as a SECOND response.created branch, which when never reaches") belong in the commit message; in the file they will drift from the code and cost every future reader attention. I'd suggest a pass that keeps why this shape is required and drops what the previous shape did. Not a blocker — just worth doing while the reasoning is fresh enough to tell the two apart.


Summary

Nothing here is structurally wrong, and I'd be comfortable with the implementation. My merge conditions are the two policy defaults (#2, #3 — or the host-side write approval that subsumes both) and one verified live call (#1). #4 and #5 are quick fixes worth folding in on the way.

Review round 40, plus the remainder of three queued reviews.

The reclaim timers I added an hour ago were never cancelled on success. The CAS
makes a late fire a no-op, so nothing was wrong — but a run_command parked a
coroutine for its full 615s budget every time, and a call that runs twenty
commands kept twenty of them alive for ten minutes each. Cancelled in
invokeOnCompletion on both surfaces.

viewer.js kept voice.seenCalls / pending / timers as plain objects, so a call_id
of "constructor", "toString" or "__proto__" reads as truthy on its FIRST
sighting: the call is dropped and the round hangs until the watchdog. Realtime
ids are call_… today, so this is defence rather than a live bug — and the Kotlin
mirror uses a HashSet and never had it, which is exactly the asymmetry the
contract test exists to catch. Object.create(null) everywhere, and pinned.

Also:
- MirrorShare's revocation ticker ran for EVERY share whether the feature was on
  or not, and did its voice.json read on Dispatchers.Default — while
  VoiceAgentStorage spawns a probe thread specifically to keep that read off a
  shared pool and BossCallingSection wraps the same call in Dispatchers.IO. Same
  call, three opinions; now two, and it does not tick at all when voice is off.
- msg.name was the one remote string echoed into a control frame unbounded. Not
  a memory hole (sendControl's ceiling closes the connection) — but the caller
  then sees a disconnect where a tool error belongs.
- fail() read audio/transport/executor when its coroutine got a slot rather than
  when it was called, so a redial in between would have torn down the NEW call.
- VoiceStatus.reason's KDoc still listed two of the four codes it emits.
- The remote toggle's description now LEADS with the fact a host most needs —
  billed to your key, and you cannot end it — instead of burying it fourth.

700 tests, 21 viewer scenarios. The prototype-safety test failed first on the
graphics state's unrelated `pending`; scoping it to `voice.` was the fix, since
a test that names the wrong file is worse than no test.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling

I read the full diff (66 files, ~11.6k added) rather than sampling, with most attention on VoiceCallService, HostVoiceCallController, JdkRealtimeTransport, JavaSoundVoiceAudioIo, both executors, the two share servers' wiring, and the viewer's call path. I did not build or run anything (per AGENTS.md the app is yours to run), so I'm taking "566 tests green" at face value.

Overall: unusually careful work for a feature this size. The things that normally go wrong in a realtime/voice integration are handled deliberately: the credential never leaves the host, the tool surface is a curated allow-list rather than "expose MCP", declaredParameters closes the wider-underlying-schema hole, scope checks happen host-side, the tool budgets form an explicit ladder (VoiceToolTimeouts) with a test pinning the ordering, and the in-flight caps have slot-reclaim for handlers cancellation can't reach. OutgoingLanes and the transport's generation counter are the two places I'd expect a subtle bug in a hand-rolled WS writer, and both are reasoned through and covered. No blocker found — my substantive comments are about the security model and what to decide before merge.

Policy, before merge

voiceCallShareEnabled = true — I agree with your own read: default it off. Today, storing a key to try the in-app pill also enables voice-driven run_command for anyone holding a control-share link, with an after-the-fact notification as the only signal. "Storing a key is the opt-in" holds for the in-app surface (caller = person at the keyboard); it doesn't transfer to a caller who is whoever has the URL. The two-toggle split is the right shape; only the default is wrong.

The daemon's "smaller surface" isn't smaller in capability. run_command/search_output are guiOnly, but send_input isn't — and send_input with a trailing \n is arbitrary command execution (builtInRules says exactly that). Fine as an implementation fact, but the description implies excluding run_command narrows what a daemon caller can do. Worth correcting so nobody later treats "daemon = read-mostly" as a security property.

Instructions aren't a control against a hostile viewer. The viewer holds an ephemeral secret and its own data channel — worth confirming whether a client can session.update instructions/tools over it (I believe it can). If so, the anti-injection and destructive-command rules are advisory for a hostile caller, and only the host-side enforcement (catalog match, canControl, scope, declaredParameters) is load-bearing. Still a sound position — a control-share holder can type into the terminal anyway — but VoiceInstructions.kt and the PR body shouldn't read as if the rules constrain someone who doesn't want to be constrained. Injection via terminal content is the real case, and your proposed host approval on the first write tool of a call is the proportionate answer; I'd land it before defaulting the remote surface on.

Spend, as a number. MAX_CALL_DURATION_MS retires the token, not the session, so the real bound is MAX_MINTS_PER_WINDOW × session length ≈ 36 mints/hour each able to run up to an hour. The KDoc says this precisely and the settings copy says "you cannot end it from here" — but neither gives the order of magnitude. Either tighten MAX_MINTS_PER_WINDOW or put the arithmetic in the toggle description.

Correctness — small, none blocking

  • HostVoiceCall.end() cancels killSwitchJob from inside that collector. It works only because scope.launch(Dispatchers.IO) { ending.end() } parents on the object's scope, not on the coroutine being cancelled. A later refactor to launch from the collector's own scope silently skips teardown, and the failure mode is an open mic after the feature is switched off. Worth a comment at the cancel site — especially as releaseIfCurrent's KDoc says "a collector cannot cancel itself from inside its own collect", which is what this path does.
  • Unreachable Error state. segmentState returns Hidden for featureEnabled == false + phase Error, and killSwitchJob is already cancelled once a call is terminal — so turning Boss Calling off after a failure (other indicators also off) parks the controller in Error with no visible Dismiss. Self-healing on re-enable, but it's the inverse of what the ordering comment claims.
  • BossCallingSection's optimistic pending never clears if the merge write fails, so the toggle shows a value that wasn't persisted — while VoiceApiKeyField beside it does surface write failures.
  • handleStart's bodyRan guard. A throw in the body outside the two guarded regions (a throwing reply) leaks a process-wide MAX_LIVE_CALLS slot for the full 30-min ceiling with no mint refund. Not live today (both replys swallow failures), but try/finally would make it structural rather than caller-dependent.
  • Viewer a11y. #voicecallbtn is a <span> + onclick with no role/tabindex/key handler, so starting a call is mouse/touch-only (consistent with #mcppill, and Mute/End are real buttons). #voicetoast has no aria-live, so "Running: …" is never announced — the thing a non-sighted caller would most want announced.

Performance

Nothing I'd change. Level meter off the state snapshot, viewer meter throttled to ~25 Hz, StampCachedValue keeping cross-process polls to a couple of stats, revocationTicks only ticking while enabled and reading on IO, audio loops on dedicated threads with the dispatcher-starvation reasoning written out. search_output's wall-clock deadline + per-line clip is real hardening of pre-existing MCP code, not just voice plumbing.

Maintainability

  • The viewer JS mirrors the Kotlin state machine (round bookkeeping, dedupe trimming, describeTool, the ladder, the error codes). VoiceCrossLanguageContractTest is a good and clearly earned mitigation, but it pins values, not behaviour — and the comments record at least two fixes that landed on one side only. Adding a tool now touches five places; the AGENTS.md note listing them is the right call.
  • Comment density. Much of VoiceCallService/HostVoiceCallController narrates bugs found and fixed in development that never shipped. The load-bearing ones (why the reservation is peeked before retiring, why expireCalls is owner-filtered, why confidential has no default) should stay. The "this used to be written the other way and was wrong" ones are archaeology git already holds, and at this volume they make the invariants harder to find. Worth a pass to keep the rule and drop the history.

Tests

Strong (~190), and on the parts that matter: mint failures never leaking OpenAI's body, the token gate (forged/retired/expired), the in-flight cap admitting exactly four and refusing rather than evicting, the capture loop's zero-vs-negative read distinction, the transport lifecycle through an injected socket, POSIX perms on the key file. Injecting openSocket/openCaptureLine/openPlaybackLine/nowMs as dependencies rather than test hooks is what makes that possible. Gaps worth naming:

  • The servers' wiring. voiceCallTokenMatches is tested, but neither handleClient's use of it is — the boundary where a refactor would drop it (its own KDoc says so). Same for the per-viewer voiceStatus push and !available → closeCalls(): the per-viewer confidential/callable reasoning is exactly what regresses to a broadcast, and nothing would fail.
  • GuiVoiceToolExecutor's MCP path — the run_commandidleFocusedPaneIdpane_id injection is untested, and it's the one place voice changes where a command lands.
  • No end-to-end call. You say so plainly, and the 500s do look account-side. Still the one thing I'd insist on: audio, live tool calls, barge-in and the mid-call failure toasts are what a user notices first.

Nice work. Merge preconditions I'd set: default voiceCallShareEnabled off; land (or explicitly defer) the first-write-tool approval; fix the daemon-surface claim in the description; and get one real end-to-end call through.

kshivang added 2 commits July 26, 2026 01:47
Reported from a live call: "it keeps on looping to itself".

There is no acoustic echo cancellation on the in-app surface. The browser gets
AEC free from WebRTC's audio pipeline; javax.sound.sampled hands us a raw
capture line and nothing else. So on speakers the microphone hears the agent's
own voice, ships it back, and the model answers itself — a self-sustaining loop
that no VAD threshold can break, because the input genuinely IS speech.

That also explains the earlier "too sensitive to noise" report, which I treated
as a VAD tuning problem. The sensitivity setting is still worth having, but it
was the wrong lever for this.

Half-duplex is the standard answer when you have no AEC: don't listen while you
talk. Mic frames are withheld while the agent is speaking, while decoded audio
is still queued or in the line's buffer, and for a 400ms tail after that — room
reverb outlives the playback. Setting is ON by default, because speakers are the
common case and the loop makes the feature unusable; turn it OFF for headphones,
where there is no echo path and full duplex means you can interrupt by talking.

The viewer now asks for echoCancellation / noiseSuppression / autoGainControl
explicitly rather than relying on the browser default being what we want.

Also: the transport logged only `javaClass.simpleName` when the event handler
threw, so a real NoClassDefFoundError in a user's session came through as
"Voice event handler failed: NoClassDefFoundError" with no way to tell WHICH
class. Host logs get the class, the message and the throwable — the redaction
rule was always about what reaches a remote viewer.

702 tests. The echo test was checked against the ungated send and fails there.
Reported from a live call: the agent cuts off mid-sentence and the audio sounds
merged. It was dropping its own speech, and I caused it two days of commits ago.

The Realtime API streams a response's audio as fast as it generates it; the
speaker drains in real time. A thirty-second answer therefore arrives in a
couple of seconds and waits in the play queue. PLAY_QUEUE_BYTES was four
seconds, so every reply longer than that had its middle discarded chunk by chunk
to make room for what followed — the caller hears the start, then a jump, with
unrelated audio spliced together. That is exactly "cutting mid-sentence" and
"voice merging".

The bound it replaced was 100 ENTRIES, described as "~4s of queued speech". The
description was wrong — deltas are server-sized, not 40ms frames — and I fixed
the description by making the number true, without asking whether four seconds
was the right number. It was not: the producer is faster than the consumer by
design. The old bound was accidentally generous, which is why it never bit.

Sixty seconds now (~2.9 MB), which exists only so a stalled line cannot grow
without limit, and play() logs once per call if it is ever reached — a chopped
reply should not be silent. MAX_DRAIN_WAIT_MS moves 8s -> 65s to match, or a
long reply cleared "Speaking" while the agent was still audibly talking.

My round-34 test asserted the four-second bound, so it encoded the bug rather
than catching it. It now asserts the two things that matter: bounded, and
generous enough that a real reply survives. Both tests fail at the old value.

703 tests.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling (1/2)

Read the whole branch. This has clearly been through several rounds already, and it shows: the reasoning that used to live in review threads is now in the code, the invariants that mattered (owner-filtered liveCalls, the deadline ladder in VoiceToolTimeouts, the guaranteed/evictable lane split in OutgoingLanes, the CAS on voiceCallTokenRef) are each pinned by a test, and VoiceCrossLanguageContractTest is a genuinely good answer to "two surfaces, one tool set."

The architecture is the right one and I am not going to re-litigate it. Below is what I think is still outstanding, roughly in the order I would want it settled.


1. Ship voiceCallShareEnabled defaulting to off

You raise this yourself as the open policy question. My read: default it false.

The asymmetry is that the two surfaces have completely different threat models but currently share one act of consent. Storing a key is a deliberate opt-in to talking to your own terminal. It is not an opt-in to "anyone already holding a control-share link can voice-drive run_command on this machine", and today it silently is both. The host notification (RemoteVoiceCalls.onActivity) is after-the-fact by construction — and per its own KDoc the audio session is browser↔OpenAI, so by the time the toast fires the host cannot actually stop it.

Three things make defaulting off cheap here rather than a discoverability regression:

  • The in-app surface is completely unaffected — HostVoiceCallController.start() only checks voiceCallEnabled.
  • VoiceAvailabilityLine already renders "In-app only — viewers see no Call BossTerm button", and BossCallingSection is rendered in the share window — the exact screen where someone is handing out a link. That is the right moment to be asked, and the copy on the toggle is already excellent.
  • Everything else in the diff exists to make a disabled reason legible end-to-end (status()voiceStatus.reasonvoiceErrorText). Defaulting off is the case that machinery was written for.

TerminalSettings.kt:1063. One line, plus whatever tests assert the default.

2. The new search_output backtracking guard does not bound the case it is for — and has no test

BossTermMcpServer grew SEARCH_SCAN_BUDGET_MS / SEARCH_MAX_LINE_CHARS / the timedOut hint. The comment is right about the problem ("regex backtracking is not interruptible... no suspension point for withTimeout to land on") but the mitigation does not reach it:

  • The deadline is checked between findAll iterations and once per line. Catastrophic backtracking happens inside a single match attempt, so neither check is ever reached.
  • SEARCH_MAX_LINE_CHARS = 8_000 does not help either: (a+)+b against 8000 as is still not going to finish. The clip bounds linear cost, not exponential.

So for a genuinely pathological pattern the thread is still pinned forever, and the honest aside in the comment ("the reclaim covers what is left") is what is actually load-bearing.

There is a standard fix that does work, because the engine calls it constantly while backtracking:

private class Deadlined(private val s: CharSequence, private val deadlineNanos: Long) : CharSequence {
    override val length get() = s.length
    override fun get(index: Int): Char {
        if (System.nanoTime() > deadlineNanos) throw RegexBudgetExceeded()
        return s[index]
    }
    override fun subSequence(start: Int, end: Int) = Deadlined(s.subSequence(start, end), deadlineNanos)
}

regex.findAll(Deadlined(lineText, deadline)), catch, set timedOut = true, break. About fifteen lines, and it turns the guard from advisory into actual.

Separately: rg -l "SEARCH_SCAN_BUDGET_MS|SEARCH_MAX_LINE_CHARS|timedOut" compose-ui/src/desktopTest returns nothing. This is the only new guard in the diff with no test, and it is the DoS mitigation for model-supplied (and, per the injection story, potentially attacker-influenced) input. A (a+)+b against a synthetic buffer, asserting the call returns with timedOut set inside a bounded wall clock, would be a good test to have — and would fail today.

3. Indirect prompt injection: worth one mechanism, not only the rule

The anti-injection rule in voiceAgentRules is a real improvement over nothing and it is framed correctly (data, not instructions; first in the block). But the remote surface still composes read_scrollback over attacker-influenceable text with run_command, and one sentence in a system prompt is not a control.

A host-side approval on the first write tool of each remote call would be proportionate — once per call, not per command, so it does not destroy the interaction. It composes with #1 rather than substituting for it: #1 decides whether strangers can call at all; this decides what the first one costs you if they can. Notably it is not needed on the in-app surface, where the caller is the person at the keyboard — a nice property of the split you have already built.

One related thing worth stating explicitly in the PR body: voiceRunInFocusedPane defaults on, so on the in-app surface the agent types into the pane the user is watching. GuiVoiceToolExecutor.idleFocusedPaneId correctly returns null for shares (mayUseFocusedPane = false) and gates on the OSC 133 RUNNING block — both right — but "the agent can type into the pane you are looking at" is a bigger default than "Run commands in the focused terminal" suggests at a glance.

4. Do not merge on green tests alone

You are upfront that no real end-to-end call has been made. I would hold on that, specifically for the things no fake can catch:

  • The session.update body (output_modalities, audio.input.format / audio.output.format with the rate you had to discover empirically) is asserted only against your own fake transport.
  • Same for the mint body's expires_after + session shape against the GA client_secrets endpoint, and the POST /v1/realtime/calls SDP exchange.
  • The two surfaces key "agent is speaking" off different event families (output_audio_buffer.* vs response.output_audio.*), and neither has been observed against a live server. clearSpeakingWhenAudible's drain-poll is exactly the kind of thing that is obviously right in a test and subtly wrong in a room.

The account-side 500 is clearly not this branch's fault, but the conclusion is the same: this is the part where a working key is not optional.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling (2/2: smaller findings, coverage)

5. GuiVoiceToolExecutor reads registry.mcpConfig through two independent paths

wrapper captures it once via by lazy; handlerName() re-reads the live @Volatile var on every call. If an embedder publishes or replaces the config after the first voice call has built the server, handlerName() computes a prefix the wrapper was not built with — toolNames() stops matching in tools() and handlerFor() returns null in execute(), so the whole surface silently collapses to the two locally-answered tools. It fails safe, but silently and confusingly. Snapshotting once fixes it:

private val cfg: BossTermMcpConfig by lazy { registry.mcpConfig ?: BossTermMcpConfig(allowWriteTools = false) }

and use cfg for both the wrapper and handlerName. (GuiVoiceToolExecutor.kt:77-88, :127.)

6. Public/internal visibility looks accidental

VoiceToolCatalog, VoiceToolDef, VoiceToolExecutor and VoiceToolException are public while every other type in the package is internal. Given compose-ui is consumed by embedders, that pins the tool-schema shape as API. StoredVoiceConfig was made internal deliberately for exactly this reason — worth confirming these four are intentional.

7. MirrorShare.revocationTicks() does not emit while the feature is off, and it sits inside a combine

combine produces nothing until every source has emitted once, so with voiceCallEnabled = false at share start the collector is dormant — and when the host turns the feature on, the viewer's voiceStatus (and Call button) lags by up to VOICE_REVOCATION_POLL_MS (5s) rather than arriving with the settings emission. Harmless, and the current shape does correctly keep the disk read out of the disabled path; emitting unconditionally while keeping keyOnDisk.get() inside the if would get both. (MirrorShare.kt:119-127.)

8. Latent: the kill-switch collector can eat a start-time error

HostVoiceCall.start() arms killSwitchJob after c.start(), correctly and for the reason the comment gives. But the collector's first emission is the current value — so if voiceCallEnabled is false, c.start() sets Error("Boss Calling is turned off in Settings.") and the collector immediately calls end(), which writes a fresh HostCallState() and cancels mirrorJob. The user gets silence instead of the message.

Unreachable today because segmentState(featureEnabled = false) hides the pill and NeedsKey is the only other entry point — but the two are one refactor apart. Either have end() preserve an Error phase, or drop the collector's first emission. (HostVoiceCall.kt:89-94.)


Test coverage

Strong — twenty files, and the choices are the right ones: RealtimeTransportLifecycleTest covering the generation counter and the connect/close race, VoiceAudioIoTest driving the capture loop through injected lines (including the n == 0 vs n < 0 distinction that was a real bug), VoiceCallTokenBindingTest isolating voiceCallTokenMatches precisely because it lives outside the service, VoiceQuietPathsTest covering parseMint's beta-shaped fallback and errorDetail's redaction. VoiceCrossLanguageContractTest pinning the Kotlin and JS mirrors against each other is the right answer to a real drift risk, and ShareViewerAssetTest asserting the CSP names api.openai.com with no wildcard is exactly the assertion that should exist.

The one gap I found is #2. The search_output guard is untested and, I believe, ineffective for its stated case — an unusual combination, and probably not a coincidence.


Verdict

Nice work. The two-surface split with one VoiceToolExecutor and one catalog is what makes this maintainable, and the honesty in the PR body about what is not verified is worth more than another round of green tests.

My merge bar would be #1 (one line), #2 (small, and a real hole), and #4 (a live call on both surfaces). #3 I would want on the roadmap with a date rather than blocking, given #1 shrinks its blast radius considerably. #5#8 are cleanups that can ride along or follow.

Reviewed by reading the diff and the resulting tree; I did not run a build or the test suite.

Reported with a screenshot: asking the agent to run a command still opened a
bottom split instead of using the focused pane.

idleFocusedPaneId keyed "is this pane free" off an EMPTY BLOCK LIST — `if
(blocks.isEmpty()) return null`, commented "no evidence shell integration is
reporting". But a terminal you just opened has announced a prompt and run
nothing, so it has no blocks either. The commonest sequence there is — open a
terminal, start a call, ask for a command — therefore always fell back to the
scratch split, which is the exact behaviour the feature was built to stop. I
conflated "nothing has run here" with "nothing is reporting here".

CommandBlockTracker now exposes hasSeenPrompt (set on OSC 133;A), which is the
actual question: has this pane ever told us anything, so that "busy" is
answerable at all. A shell with no integration still falls back, because there
busy and idle really are indistinguishable.

The decision is now a pure shouldUseFocusedPane(...) with its own test covering
all five branches. It was buried in a registry lookup and unreachable in a test,
which several reviews flagged as a gap and which is how this shipped inverted
for the one case that matters most.

708 tests.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review: Boss Calling (voice-call the session's AI agent)

I read the whole branch — both call surfaces, both share servers, the viewer, the tool catalog and executors, the audio/transport layers, and the settings/UI plumbing. Overall this is unusually careful work: the layering (VoiceCallService as the one policy point, VoiceToolExecutor as the one scope boundary, RealtimeTransport/VoiceAudioIo injected so the state machines are testable without hardware) is right, and the security posture is thought through much further than most features of this size.

Worth crediting before the criticism, because these are the parts that are easy to get wrong and were got right:

  • Gating the ek_… reply on confidential (the E2E handshake) rather than on "is the transport TLS" — correct, given these links routinely ride a TLS-terminating tunnel — and making confidential/canControl required parameters with no defaults on status(), handleStart() and initialMessages(). That is the right way to make a fail-open default impossible rather than merely discouraged.
  • Key in a separate chmod-600 file, StoredVoiceConfig.toString() overridden, chmod applied to the temp file before the key bytes are written, atomic move. 60s expires_after on the minted secret.
  • Per-viewer voiceStatus computation on both push paths, and the re-send with a reason after a mid-session control upgrade.
  • Unconditional https://api.openai.com in connect-src, with the reasoning written down. Both narrower alternatives really are worse.
  • VoiceContextSnapshot.field sanitizing terminal-controlled titles and cwds before they reach the system prompt, including U+2028/9 which isISOControl and Java's ASCII \s both miss.
  • The two-lane outbox, so a run_command output flood cannot evict a queued voiceToolResult — with the guaranteed lane still bounded.
  • takeSurrogateSafe in clampToolResult.

Findings below, roughly in the order I would want them addressed.

1. Uninterruptible regex can pin Dispatchers.Default workers app-wide

MirrorShare.coro is Dispatchers.Default (MirrorShare.kt:87), and VoiceCallService.handleToolCall runs the executor on that scope. search_output hands a model-supplied pattern to Regex(...) and runs it per scrollback line.

The new mitigation in BossTermMcpServer.kt (SEARCH_SCAN_BUDGET_MS, SEARCH_MAX_LINE_CHARS) checks the deadline between lines and between matches — but catastrophic backtracking happens inside a single find() attempt. (a+)+$ against one clipped 8000-character line never reaches a deadline check. So the budget bounds the accidental case, as its comment says, but not the adversarial one.

What makes this more than a stuck tool call is that the slot-reclaim design explicitly gives up on the thread:

"The thread stays pinned (nothing here can fix that from outside), but capacity comes back."

Capacity coming back is exactly what lets it repeat. Four pinned Default workers roughly every 13s, indefinitely — and Dispatchers.Default is shared with terminal rendering collectors, MirrorShare's broadcast loop, TabbedTerminal's flows, and now the voice status pollers. That is an app-wide freeze, not a voice-feature outage.

Reachability is not just a control-link holder: it is also reachable through prompt injection, the threat the PR itself names. Scrollback that talks the agent into one search_output call is enough, with no remote attacker at all.

Two suggestions, ideally both:

  • Contain the blast radius: run voice tool execution on a dedicated bounded dispatcher (Dispatchers.IO.limitedParallelism(MAX_IN_FLIGHT_TOOL_CALLS)) instead of Default. Cheap, and it turns "the app freezes" into "voice tools stop working".
  • Make the match itself interruptible: java.util.regex calls charAt on its CharSequence, so wrapping the line in a CharSequence whose charAt throws past a deadline genuinely aborts a runaway match. That is the standard fix, and it makes SEARCH_SCAN_BUDGET_MS mean what it says.

2. The two open questions in the description — my vote

Both are raised honestly in the PR, so this is a vote rather than a finding.

  • voiceCallShareEnabled should default false. Storing a key to try the in-app call should not, in the same act, hand voice-driven run_command to anyone already holding a control link. The in-app caller is the machine's owner; the remote one is not, and that is a different enough boundary to deserve its own deliberate flip. The KDoc in TerminalSettings.kt already records this recommendation — I would take it.
  • A host-side approval on the first write tool of a remote call is the proportionate guard for the injection surface. The first bullet of voiceAgentRules is worth having, but it is a request, not a mechanism, and the agent holds run_command while reading text an attacker can influence. One confirmation per call (not per tool) keeps it usable.

Related, and a shipping decision rather than a bug: voiceCallEnabled and voiceShowStatusIndicator both default on, so every existing user gets a new "Call BossTerm" pill in the status strip on upgrade, for a feature they have not opted into. Idle-until-keyed is the right behaviour, but the pill is still a visible change to a corner some users deliberately cleared.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

(review, part 2 of 2)

3. JdkRealtimeTransport.connect() — the generation bump is one step too late

socket = ws
open = true
val gen = generation.incrementAndGet()

close() can time out joining a writer parked inside sendText(...).join() — which is precisely why generation exists. But between open = true and incrementAndGet(), a writer surviving from the previous connect sees keepWriting(open = true, myGen == currentGen), polls, reads the new socket, and sends on it. If the new writer then starts while that send is in flight, you get the two-concurrent-sendText collision the counter was added to prevent — which the writer now treats as terminal for the socket, so it surfaces as a call that dies on connect.

Narrow (it needs a writer that outlived WRITER_SHUTDOWN_MS, sitting in lanes.poll), and unreachable today because HostVoiceCall builds a fresh controller per call. But the transport documents itself as reusable and the whole generation mechanism exists for that case. One-line fix:

val gen = generation.incrementAndGet()
socket = ws
open = true

4. MirrorShare.revocationTicks() gates a combine input on the feature switch

combine emits nothing until every source has produced a value, and revocationTicks() only emits while voiceCallEnabled is true. So on a host that starts with Boss Calling off, the entire voiceJob collector never runs — including closeCalls() and the per-viewer status pushes.

Benign today (no call can exist while the switch is off, and initialMessages carries the correct status at admit), and it self-heals within 5s of the feature being turned on. But it makes a heartbeat conditional on the very thing it is a backstop for, which is the shape of a future bug. Emitting unconditionally and skipping only the keyOnDisk.get() disk read would be more obviously correct and costs nothing.

5. Test gap: the search_output limits have no test

SEARCH_SCAN_BUDGET_MS, SEARCH_MAX_LINE_CHARS and the timedOut hint are the mitigation for §1, and are the only new logic in this PR with no coverage at all — rg over desktopTest finds no references to any of the three. Given how much else here is tested, including things far less security-relevant, that stands out. A test that a long line is clipped and that a scan over a large buffer reports timedOut would at least pin the contract.

Coverage is otherwise genuinely strong. VoiceCallServiceTest exercising refund-on-refusal, the call-token gate and the in-flight cap against the real constants — rather than a mirrored = 8 in the test file — is the right instinct, and VoiceCrossLanguageContractTest pinning viewer.js against the Kotlin error-code list is a good answer to a real drift risk.

6. Smaller things

  • MirrorShare.removeViewer calls voiceService.closeCall(vc.voiceCallToken) unconditionally, which forces the by lazy voiceService and its voiceExecutor on every disconnect, including on shares that never hosted a call. stop() carefully guards the same work behind voiceStarted; this path undoes that intent. if (vc.voiceCallToken != null) would keep the two consistent.
  • status() reason precedence versus its own KDoc. no_key is evaluated before not_controller and is redacted for a non-controller, so a view-only daemon viewer on a keyless host still gets available:false, reason:null — "no bar and no explanation", which is exactly the silence the callable parameter's KDoc claims to have closed. The behaviour is defensible (do not leak host configuration to a guest); the comment overstates what was fixed.
  • Comment density. A style call, not a defect, but much of the KDoc is post-mortem archaeology of bugs already fixed on this branch ("this used to…", "eight tests stayed green while the fix did nothing", "which describes the opposite of the code"). It is excellent review material and I used it. As maintenance material it inverts the ratio: VoiceCallService.kt is ~890 lines, a large fraction of it narrative about states the code no longer has. Consider moving the history to a docs/boss-calling.md and keeping inline only the invariant statements ("this must run before that, because X") where they are load-bearing.
  • The AGENTS.md addition is good and correctly warns that the two surfaces key off different event families. Worth also noting there that MirrorShare runs voice tools on Dispatchers.Default, if §1 is not addressed.

Summary. I would want §1 fixed (or at minimum the dispatcher contained) and §5 covered before merge; §3 and §4 are cheap hardening. §2 is a product call, and my vote is default-off for the remote surface plus a first-write-tool confirmation. The rest is polish. And since the description notes a real end-to-end call has not been verified against a working key, the audio path, live tool round-trips and the mid-call failure toasts still need that pass too.

The curated catalog advertised nine tools; BossTermMcpServer registers thirteen.
run_in_panel, show_image and read_debug_console were excluded on the reasoning
that a voice agent doesn't need them — which is a fair call for a REMOTE caller
and the wrong one for the machine's owner, who already reaches all of them from
their own MCP endpoint.

In-app calls now advertise every registered tool, with the description and input
schema taken from the server rather than restated. That means a tool added to
BossTermMcpServer reaches the voice agent with no second edit, and the two can
never describe the same tool differently — the drift this branch has had to fix
repeatedly elsewhere. Write classification comes from the server's own read/write
split rather than being guessed at the voice layer.

Shares are deliberately unchanged and keep the curated catalog. That is not
caution for its own sake: the declaredParameters allowlist is what stops a guest
passing `panel: "new_tab"` and running commands in a tab the share does not
mirror. The same allowlist code covers both kinds without a branch, because it
reads whatever the def declares — the curated set for a catalog tool, the
server's schema for an MCP one.

Settings → Boss Calling → "Give the agent every MCP tool", on by default.

712 tests.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review: Boss Calling (#345)

Read through the whole voice package, both share servers' wiring, viewer.js, the settings/UI surfaces and the test suite. This is unusually careful work — the KDoc on VoiceCallService, HostVoiceCallController, RealtimeTransport and VoiceAudioIo doesn't just describe what the code does, it records the failure that motivated each guard, which made reviewing 12k lines tractable. A few specifics worth naming because they are the parts that are easy to get wrong and are right here:

  • The credential story holds up end to end: key only ever in Authorization on the mint, 60s expires_after, ek_… handed over only on a connection that completed the Kex (not merely TLS) — making handleStart's confidential a required parameter with no default is the right call given tunnels terminate TLS.
  • VoiceToolCatalog.declaredParameters allowlisting in both executors is the load-bearing bit of "targets are limited to the share" — without it panel: "new_tab" on run_command walks straight out of scope. Good that both executors do it identically.
  • VoiceContextSnapshot.field sanitizing titles/cwds before they land in the system prompt (not a tool result) is the injection surface most people miss.
  • VoiceToolTimeouts collecting the four-rung ladder into one testable object, and voiceCallTokenMatches extracted specifically so the boundary survives a refactor.
  • OutgoingLanes splitting droppable audio from guaranteed protocol frames, and the writer treating a failed sendText as terminal rather than a lost frame — that is the difference between a mute call and a diagnosable one.

Findings below, most important first.


1. StatusStrip early-return defeats the remote-call indicator — bug

compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/share/StatusStrip.kt:68

val showCall = call != CallSegmentState.Hidden
if (!showMcp && !showSharing && !showCall) return

remoteCalls is not in the guard, so the body at line 90 (if (showSharing || remoteCalls > 0)) is unreachable when all three indicator toggles are off. Concretely:

  • mcpShowStatusIndicator = false, sessionSharingShowIndicator = false, voiceShowStatusIndicator = false (all user-settable, all documented as cosmetic opt-outs)
  • segmentState(...) returns Hidden because !indicatorEnabled and the host is not on their own call
  • a remote viewer starts a call → remoteVoiceCalls == 1TabbedTerminal.kt:2162 correctly renders the ColumnStatusStrip returns at line 68 → nothing is drawn

And per RemoteVoiceCalls' own KDoc, NotificationService.showNotification is a log line off macOS. So on Linux/Windows in that configuration a control-link holder can start an agent that runs commands here with no user-visible signal at all — precisely the gap RemoteVoiceCalls and the remoteCalls parameter were introduced to close. Both KDocs (StatusStrip.kt:53-61, :86-89, RemoteVoiceCalls.kt:18-30) assert it is closed.

if (!showMcp && !showSharing && !showCall && remoteCalls <= 0) return

There is no StatusStripTest anywhere in the repo, which is why this got through — worth adding one alongside the fix, since this composable is now carrying a security signal rather than just chrome.

2. The default-on remote surface — agreeing with your open question

You flag this yourself, so just registering a vote: ship voiceCallShareEnabled = false.

The argument in TerminalSettings.kt:1044-1050 — "storing a key is the deliberate opt-in" — is sound for the in-app surface and does not transfer to the remote one. Someone who adds a key to try talking to their own terminal has consented to that; they have not consented to "anyone already holding a control-share link can voice-drive run_command here." Two things make it worse than the usual default-on argument:

  • the host cannot revoke a call once minted. MAX_CALL_DURATION_MS, closeCalls() and the master switch all retire the token; the audio session is browser↔OpenAI and keeps billing. Your own comment on MAX_CALL_DURATION_MS says this plainly. A default that cannot be undone after the fact should be opt-in.
  • the strip and the toast are both after-the-fact signals, and per finding feat: Terminal rendering improvements + 4 high-priority features (#2, #3, #4, #5) #1 the strip is currently not reliable.

A one-time host confirmation on the first remote call would also work and is arguably better UX than a toggle nobody finds. Either way I would not merge with both defaulting on.

3. Prompt injection × voiceRunInFocusedPane defaulting on

VoiceInstructions.kt:47-52 is a good rule and I am glad it is first in the block, but as you say it is not a mechanism. What raises the stakes here specifically is voiceRunInFocusedPane = true combined with mayUseFocusedPane = true for the in-app call (HostVoiceCall.kt:65): the agent types into the user's live shell, in their cwd, with their environment — not a scratch split. The idleFocusedPaneId OSC-133 guard rules out "something is running", not "this command is a good idea".

So the chain is: agent reads a build log / curl output / README via read_scrollback → that text is attacker-influenceable → agent holds run_command → the command lands in the user's real shell. A host-side approval on the first write tool of a call (your suggestion) is the proportionate guard; defaulting voiceRunInFocusedPane off would be the cheap partial one. I would take the former.

4. No end-to-end call — treat as a merge blocker

I would hold merge on this rather than land and fix forward. Everything below is an unverified API-contract assumption with no integration coverage, and the failure modes are all "silence with a plausible-looking log line":

  • the mint body shape (expires_after / session.type / output_modalities / GA top-level value)
  • session.update's shape, including the session.audio.output.format.rate requirement you found by rejection
  • POST /v1/realtime/calls with no query string
  • the two different event families the two surfaces key "agent is speaking" off — output_audio_buffer.* in viewer.js vs response.output_audio.* in HostVoiceCallController.onEvent. Nothing cross-checks those against the real API, and a rename on either side degrades silently (bar stuck on "Listening…", or barge-in never firing).

The unit suite is genuinely strong, but every one of these lives in the seam it cannot reach.

5. describeTool / voiceDescribeTool diverge on the arg-less fallbacks — minor

HostVoiceCallController.kt:808-825 vs voiceDescribeTool in viewer.js. When a required arg is absent the two disagree:

tool Kotlin viewer.js
run_command Running a command… Working…
search_output Searching… Working…
send_input Typing… Working…
send_signal Sending a signal… Working…

CAPTION_FIXTURES in VoiceCrossLanguageContractTest only pins cases with the arg present, so the contract test passes. Unreachable in practice — all four params are required in the catalog — but both files claim to mirror each other, and the whole point of that test class is catching exactly this shape of drift. Either add the arg-less fixtures or make the viewer fall through to the same strings.

6. DaemonVoiceToolExecutor.get_active_tab still always reports isActive: true — minor

DaemonVoiceToolExecutor.kt:99-102 calls tabInfoJson(target, viewing = target), so s.id == viewing at line 145 is tautological. The comment at :135-136 says it "used to be hardcoded true"; on this path it effectively still is. The GUI executor's tabInfoJson does a real check against registry.findState(tabId)?.activeTabId, so the two surfaces answer differently for the same tool. Either give the daemon a real notion of "active" or drop the field there rather than assert something meaningless.

7. No inbound WebSocket frame cap — hardening, pre-existing

SessionShareManager.kt:772 and DaemonShareServer.kt:559 both install(WebSockets) with no maxFrameSize, so Ktor's default is effectively unbounded. MAX_ARGS_CHARS (32 KiB) and MAX_CALL_ID_CHARS are checked in handleToolCall after the frame has been fully buffered and deserialized into a ClientMessage. Pre-existing (Input has the same shape), so not this PR's fault — but voiceToolCall.argsJson is a new field explicitly designed to carry a large-ish payload from an untrusted viewer, and a maxFrameSize line is cheap while you are in here.

8. revocationTicks() can silence the whole voiceJob collector — nit

MirrorShare.kt:119-127 only emits while voiceCallEnabled is true, and combine needs every source to emit once before it produces anything. So with the feature off at share start, the collector at :242 never runs at all. Benign today — admit already sends voiceStatus, and there are no calls to revoke while the switch is off — but it means the collector's first emission depends on a flow that can be permanently silent, which is fragile if anything else is ever added to that block. An .onStart { emit(Unit) } (keeping the enabled check for the disk read) would make it unconditional without changing the polling behaviour.


Verification note: I reviewed by reading; I did not run a build or the test suite (per AGENTS.md I do not run the app, and the gradle invocation was not available in this environment). Findings 1, 5, 6 and 8 come from reading the code paths directly and I am confident in the mechanics; 4 is your own caveat restated with a recommendation attached.

Nice work — the security reasoning here is a notably higher standard than the feature required.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review — Boss Calling (PR #345)

I read the final state of the branch rather than the 57-commit history: the whole compose-ui/.../voice/ package, both share servers' voice wiring, viewer.js's call path, the CSP, the settings surface, and the new tests. I did not run a build here, so everything below is from reading.

Overall this is unusually careful work. The failure modes that normally sink a feature like this are all handled explicitly and correctly: the ephemeral-secret boundary, the E2E gate before handing over a credential, per-connection token binding, mint refunds on every refusal path, slot reclamation independent of a wedged handler, the generation counter on the WS writer, the single-use audio latch, the "withTimeout can't cancel CPU-bound work" problem. The VoiceToolTimeouts ladder in one file with a test asserting the ordering is exactly the right shape for constants whose relative order is load-bearing. VoiceContextSnapshot sanitizing terminal-controlled titles before they land in the system prompt is the kind of thing that usually gets missed entirely.

The findings below are mostly about defaults and one stale invariant, not about the mechanics.


1. voiceExposeAllTools defaults to true, which hands the in-app agent manage_tools

GuiVoiceToolExecutor.allRegisteredTools() returns everything registeredToolInfo() reports, filtered only by disabledMcpTools. manage_tools is always registered (BossTermMcpServer.kt:205-207) and is in UNDISABLABLE_TOOLS, so it reaches the in-app voice agent by default.

Its own description: "changes apply live (the tool list updates without restarting the server) and persist to settings.json."

This is in tension with the rest of the file. GuiVoiceToolExecutor.server() is @Synchronized specifically so the user's current disabled set is re-applied before every tool call — the KDoc calls that "exactly the invariant this method exists to establish." With manage_tools on the surface, an agent can re-enable a tool the user deliberately disabled and call it within the same turn, and the change is persisted to disk. Given the PR openly acknowledges indirect prompt injection (scrollback → run_command), this is the one tool that converts an injection into a durable config change.

Good news: availableToolNames() respects config.allowWriteTools, so applyDisabledSet can't re-register write tools on a read-only embedder config — that boundary holds. The user's own disabledMcpTools boundary does not.

Suggestion: exclude manage_tools (and probably read_debug_console) from allRegisteredTools regardless of the setting. It is a one-line filter and costs the agent nothing it needs.

2. The pane_id invariant in GuiVoiceToolExecutor.execute is no longer true

The pane id comes from the HOST, never the model — pane_id is not in the voice catalog's declared parameters, so this cannot be steered by an injected instruction to point at some other pane.

That held when the catalog was the only source of def. Under voiceExposeAllTools = true (the default), def comes from allRegisteredTools, whose parameters are the MCP schema — and run_command's schema declares pane_id, panel, split_ratio and working_dir (BossTermMcpServer.kt:1439-1463). So args.forEach { if (k != "tab_id" && k in allowed) put(k, v) } now passes all of them through.

The host's own put("pane_id", ...) runs after the loop, so it wins when idleFocusedPaneId returns non-null. But it returns null exactly when the focused pane has a RUNNING block, or voiceRunInFocusedPane is off, or there is no OSC 133 — and in those cases a model-supplied pane_id is honoured verbatim. So the "don't interleave with something already running there" guard that shouldUseFocusedPane exists for is bypassable by the model naming the pane directly.

Not a scope escape (in-app scope is every tab anyway), but the comment asserts a property the code no longer has, and shouldUseFocusedPane's stated point is that these are "behavioural decisions about typing into someone's live shell." Either drop pane_id/panel/working_dir from the allowlist even in expose-all mode, or correct the comment to say the guard is advisory.

3. Remote-call default: I would land voiceCallShareEnabled = false

You flagged this yourself, so briefly. voiceCallEnabled = true + voiceCallShareEnabled = true means storing a key to try the in-app call also arms voice-driven run_command for anyone already holding a control-share link. The host notification is after-the-fact, and — as MAX_CALL_DURATION_MS's KDoc honestly documents — the host cannot hang up the audio session at all.

The approval primitive already exists: MirrorShare.kt:502 uses SessionShareManager.awaitApproval(tabId, vc.name, wantsControl = true) for control upgrades. Routing the first voiceStart per connection through the same flow would be small, consistent with existing UX, and turns the notification from a signal into consent. That plus defaulting the toggle off seems like the right pre-merge posture; the in-app surface is unaffected either way.

4. connect-src https://api.openai.com is unconditional

The reasoning in the comment (CSP is baked at page load, so gating it breaks a call started after the host flips the toggle) is sound as far as it goes — but index.html is already templated per request (__BOSSTERM_WS_ORIGINS__ in ShareViewerResources.kt), so gating on voiceCallEnabled && voiceCallShareEnabled at serve time is available, and the failure mode is "reload the page." As it stands, every viewer served by every host — including hosts who will never store a key — ships with an outbound origin the page did not previously have. With script-src 'self' this is a narrow widening, but one that buys nothing for the majority of hosts.

5. Smaller things

  • Process-wide mint budget is a cross-share denial vector. One redial-looping viewer on share A locks out a legitimate caller on share B, and both see an identical rate_limited. The KDoc names this and names the fix (a per-connection sub-budget under the global one). Worth doing — it is cheap, and the current behaviour is indistinguishable from a bug to whoever gets locked out.
  • MAX_LIVE_CALLS = 8 is unreachable. With MAX_MINTS_PER_WINDOW = 6 per 10 minutes and a 30-minute call ceiling, you can never mint an 8th live call inside a window. Either the cap is dead or the two constants should be reconciled.
  • HostVoiceCall.start()'s kill switch can eat the error message. The collector is armed after c.start(), and StateFlow.collect replays the current value — so with voiceCallEnabled false, c.start() sets Error("Boss Calling is turned off in Settings.") and the collector immediately calls end(), resetting to Idle. Only reachable via a race (the pill is Hidden when the feature is off), but .drop(1), or an if (state.active) guard inside end(), makes it deterministic.
  • expireCalls queues onCallExpired for every expired token, announced or not. expiredTokens += expired.keys is not filtered the way the announced count is, so a reservation that never became a call can still push a stale_call at a connection. Harmless, just asymmetric with the announced handling right beside it.
  • Comment density (style note, not a request). A lot of the KDoc is archaeology — "this used to do X, which broke Y." That is genuinely valuable on the subtle concurrency (generation, the disposed latch, keepWriting), where the reason is not recoverable from the code. It is less valuable on the straightforward paths, and finding 🔍 Implement text search/find functionality (Ctrl+F) #2 is a live example of the risk: a comment asserting an invariant a later commit quietly invalidated, with nothing to fail when it did. Where an invariant is worth a paragraph, it is usually worth a test that breaks when it stops holding.

Test coverage

Strong. The ones that matter most are present: the cross-language contract test pinning viewer.js's error codes and dedupe behaviour to the Kotlin side, the timeout-ladder ordering test, the "a 5xx never forwards OpenAI's body" test, the in-flight-cap-admits-exactly-four test. RealtimeTransportLifecycleTest driving connect/close/reconnect through an injected socket factory is the right call for the subtlest file in the PR.

Two gaps worth closing:

  • No test for the server-side message routing. voiceCallTokenMatches is covered as a pure function, but nothing exercises MirrorShare.handleMessage / DaemonShareServer actually calling it, nor the fact that voice messages are deliberately handled before the if (!vc.canControl) return gate. That ordering is load-bearing (it is what produces not_controller instead of silence) and is currently held only by two hand-written when blocks that must stay in step.
  • manage_tools is not asserted either way in FullToolSurfaceTest. Whichever way feat: Terminal rendering improvements + 4 high-priority features (#2, #3, #4, #5) #1 lands, pin it there — that test already enumerates exactly the tools expose-all is supposed to add.

Nothing here blocks the mechanics. #1 and #2 are what I would want fixed before merge; #3 is the policy call. The unverified end-to-end call remains the real outstanding risk, and you have already named it.

kshivang added 6 commits July 26, 2026 12:26
The in-app surface withheld the microphone for the whole of every agent
reply, on the theory that a machine on speakers hears itself and answers
its own voice. That self-loop turned out not to reproduce; what did
reproduce is the cost, which is that interruption stopped working.

Interruption runs off the server's `input_audio_buffer.speech_started`.
That event can only arrive if the frames carrying the user's speech were
actually sent, so withholding them didn't degrade barge-in — it removed
it. Cutting the agent off mid-sentence is most of what makes a voice
assistant usable, and it is not worth trading for a loop nobody hit.

Removes the suppression, its `voiceEchoSuppression` setting and toggle,
and the level-based barge-in detector written to work around it. A test
pins the mic staying open while the agent speaks, verified against the
old behaviour: with the withhold restored it fails.
Both blanket answers failed on real calls. Sending everything makes the
agent talk to itself: there is no echo cancellation on this surface, the
microphone hears the reply, and the server's VAD correctly calls it
speech. Sending nothing removes interruption altogether, because
barge-in runs off the server's `speech_started` and that cannot fire for
frames withheld locally.

So the gate has to judge level, and the previous attempt at that failed
for a recorded reason: it demanded an absolute RMS of 0.28 before
calling anything a voice. VoiceAudioIo's own comment puts conversational
speech at 0.05-0.15, so the bar sat near shouting and nothing ever
cleared it — indistinguishable from sending nothing.

VoiceDuplexGate measures both sides instead of assuming either: the echo
level from frames arriving while the agent talks, and the user's own
level from frames the SERVER confirmed as speech during their turn. The
bar sits between the two, so it adapts to a laptop speaker at 30% and a
desk speaker at full. Where the echo returns louder than the user's own
voice no bar can do both jobs; it prefers rejecting the loop and records
why, so the log names the cause. The echo estimate carries across
replies, which leaves no dead window at the start of each one.

Interruptions log their measured level, bar, and both estimates, so the
constants can be tuned from a real room rather than guessed again.

Verified against both failure modes: with the gate bypassed the echo
assertion fails, with it forced closed the barge-in assertion fails.
The gate cut the agent off mid-sentence on every reply. The cause is
invisible from the gate's own code: play() QUEUES audio, so sound does
not leave the speaker for some time after `speaking` becomes true. The
frames captured in that window are room silence, not echo. Warm-up
measured exactly them, anchored the echo estimate near zero, and the bar
— a point between that estimate and the user's level — collapsed to the
0.03 absolute floor. The real echo then arrived at an ordinary 0.05,
cleared the floor three frames running, and read as a voice.

The mistake was letting a low echo estimate pull the bar DOWN. A quiet
echo is not evidence that a quiet sound is the user; only the user's own
measured level is evidence of that. So the bar is now the higher of
user × 0.7 and echo × 1.6: the echo term can raise it for rooms where
the reply comes back loud, and nothing can push it beneath what the user
actually sounds like.

With no measurement of the user at all the gate now withholds rather
than deriving a bar from the echo estimate alone — that fallback was the
same bug by another route. Only reachable when the agent speaks first,
and it costs one un-interruptible greeting.

Verified against the old bar: the latency scenario reproduces at
bar=0.03 interrupting on frame 3, and a quiet room drops the bar to
0.045 against a 0.20 voice. Both now hold.
Measurements from a real call settle what two earlier attempts guessed
at. The log line added for exactly this purpose reported:

  level=0.083 bar=0.038 echo=0.024 user=0.004
  level=0.102 bar=0.038 echo=0.024 user=0.004

Two findings. The user estimate was 0.004 — silence — because
observeUserSpeech averaged every frame between speech_started and
speech_stopped, and server VAD only ends a turn after ~700ms of silence.
All of it landed in the average, so the user term contributed nothing and
echo x 1.6 set the bar by itself. Silent frames are now skipped, and the
estimate uses a fast attack with a slow release so pauses cannot erase
what a voice measures.

The second finding is the reason no threshold on the microphone could
have worked: the reply comes back at 0.06-0.10 RMS and ordinary speech
measures 0.05-0.15. There is no level that divides them. The microphone
cannot tell you whose voice it is.

So the reference is the speaker, not the microphone.
VoiceAudioIo.audiblePlaybackLevel reports how loud the audio currently
LEAVING the device is, looked up against the line's own play position —
the buffer is ~0.6s deep, so write time would mis-align the reference by
more than the echo it predicts. Echo is that signal attenuated by the
room, a factor the gate learns rather than assumes, and anything
materially above it is a second voice. The bar therefore tracks the
agent's envelope: in the gaps between its words the reference falls, the
bar falls with it, and an interruption lands at once.

The gate now also measures before it judges. Both previous versions
started their estimate at zero and only updated it from frames already
judged to be echo, so the opening frames of a reply were tested against
nothing and cleared a floor. Ten frames of real playback are required
first, once per call, since a room's acoustics do not change per reply.
Where the echo returns at nearly the level it was played, no bar can
admit the user and reject it; rejecting wins, and the log says
SPEAKERS-TOO-LOUD rather than presenting as flaky barge-in.

Verified against both mistakes: neutralise the reference and the echo
interrupts at bar=0.060, reproducing the reported failure; key the
reference off write time instead of the play position and it reports a
chunk that is not audible yet.
send_input writes to the shell's stdin verbatim and presses nothing — its
own contract puts the newline on the caller. Text sent without one sits
at the prompt unexecuted, and the agent then reads a scrollback with no
result and reports the command as hung or failed. That is a far more
confusing outcome than a missing keystroke, and it is invisible from the
tool's name.

The requirement was only stated in two of the three places it applies:
the missing-shell-integration fallback and the branch for surfaces
without run_command. The branch that actually recommends send_input for
interactive programs said nothing about it. It is now one rule, stated
wherever the tool is available, covering shell commands, REPL lines, and
answers to interactive prompts alike.

\r\n rather than either character alone: a bare \n submits in most shells
but not in every interactive program, and CRLF is what a terminal sends
for Enter.

Verified by removing the rule: the test fails.
Without this the agent attempts refactors as a series of sed one-liners
narrated over voice, which is the worst available way to do them. Claude
Code is already on the machine — the same CLI the AI menu launches and
CliBinaryResolver resolves for MCP attach — and is far better suited to
multi-step code work than a voice turn.

The mechanics are stated rather than left to be discovered, because
getting them wrong looks like the tool being broken: an interactive
`claude` enters the alternate screen, which is exactly the case
run_command refuses with "TUI detected". So the rule names `claude -p`
for the non-interactive path, send_input + read_scrollback for a session,
and says to type into a session already open in the pane instead of
starting a second one.

Only stated for surfaces that can actually launch it — a read-only
embedder is not told about a CLI it has no way to run.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review: Boss Calling — part 1 (findings that matter)

Read the whole voice package, both share servers' message handling, the viewer's call code, the settings surface, and the new tests. This is a large feature carried with unusual care: the ceiling ladder in VoiceToolTimeouts, the slot reclaim that doesn't trust the handler to finish (in both executors), OutgoingLanes' two-lane split, VoiceContextSnapshot sanitizing terminal-controlled text before it reaches a system prompt, and the honest accounting of what the mint budget actually bounds. The description also flags its own weakest spots rather than burying them, which made this much faster to review.

Four things I'd want settled before merge.

1. voiceExposeAllTools + a toolNamePrefix breaks every in-app tool call

GuiVoiceToolExecutor.allRegisteredTools builds each def with name = info.name (GuiVoiceToolExecutor.kt:166), and BossTermMcpServer.registeredToolInfo() returns tool.name — which is already prefixed (toolName(builtin) = config.toolNamePrefix + builtin). Then execute() re-applies it:

val registeredName = handlerName(def.name)   // prefix + prefix + bare
val handler = server().handlerFor(registeredName) ?: throw VoiceToolException(...)

With a non-empty toolNamePrefix and voiceExposeAllTools = true (the default), every in-app tool call fails with "Tool not available on this host". The locally-answered tools go with it: when (name) { "list_tabs" -> ... } compares bare names, so a prefixed def.name skips that branch and falls through to the same broken lookup — losing the scope-filtered local answers too. The !in disabled filter on line 164 also compares a prefixed name against a bare-name set, so it's a silent no-op (harmless in practice, since applyDisabledSet already pruned the server, but it reads as a working check).

The existing prefix test (GuiVoiceToolExecutorTest:192) runs with the default mayUseFocusedPane = false, so it covers the curated path, which correctly emits bare names. FullToolSurfaceTest only uses BossTermMcpConfig() with an empty prefix. That combination is exactly the untested cell.

Fix: have RegisteredToolInfo carry the bare name (stripPrefix is already right there) so handlerName() composes once, and add a FullToolSurfaceTest case with a prefix.

2. manage_tools reaches the in-app agent, and it can rewrite disabledMcpTools

registerManageTools(server) is registered unconditionally and sits in UNDISABLABLE_TOOLS, so registeredToolInfo() includes it — and with voiceExposeAllTools = true by default, allRegisteredTools advertises it with no filter. HostVoiceCallController.handleFunctionCall has no write gate (correct — the caller is the owner), so the agent can call it and persist changes to disabledMcpTools in settings.json, affecting the whole MCP surface rather than just the call.

That undercuts the invariant GuiVoiceToolExecutor's own KDoc states ("user tool-disables carry over to voice for free"), and it's reachable through the indirect-injection channel the PR already acknowledges: the agent reads scrollback (build logs, curl output) while holding run_command and the switch that re-enables whatever the user turned off.

Related: registeredToolInfo().write is writeToolRegistrations.containsKey(...), which does not contain manage_tools — so it's classified as a read tool. Nothing gates on that today (the share path resolves against VoiceToolCatalog.ALL, so it never resolves there), but if the full surface is ever routed through a canControl-gated path it would sail through the write check.

Fix: exclude BossTermMcpServer.UNDISABLABLE_TOOLS from allRegisteredTools. FullToolSurfaceTest asserts run_in_panel / show_image / read_debug_console are exposed but says nothing about manage_tools — pin whichever answer you pick.

3. Policy defaults — agreeing with the PR's own open question

voiceCallShareEnabled = true (TerminalSettings.kt:1063) means storing a key to try the in-app call also enables voice-driven run_command for anyone already holding a control-share link. Calling the start notification "an after-the-fact signal, not consent" is right; RemoteVoiceCalls' persistent counter is a genuine improvement over the toast (and spotting that showNotification is a no-op off macOS is a good catch), but neither is a decision point.

I'd default it off. The in-app surface — where someone setting a key first goes — keeps working, and the remote surface becomes a second deliberate act. Pairing that with the one-time host confirmation the PR proposes would also be the only real mitigation for injection there: the rule in voiceAgentRules is worth its sentence, but it isn't a mechanism, and the code says so honestly.

Same question, milder, for voiceExposeAllTools = true: it silently reverses every exclusion VoiceToolCatalog's KDoc argues for. Off by default makes the curated set the baseline on both surfaces.

4. The live path is still unverified

Agreed with the PR that this needs one real call before merge. Specifically unexercised today:

  • Mint body field names against the GA POST /v1/realtime/client_secrets schema — VoiceCallServiceTest drives a loopback stub, so a wrong key inside session.audio.* gives a green test and a 400 in production. The session.audio.output.format.rate comment in sessionUpdate shows exactly how that fails.
  • The SDP exchange in connectRealtimeShareViewerScriptTest loads and initializes viewer.js but can't negotiate.
  • Real javax.sound.sampled lines: VoiceAudioIoTest injects both line factories (good design), so 24 kHz PCM16 line acquisition, longFramePosition semantics, and the echo-reference alignment have never run against hardware.
  • Mid-call failure toasts and the FATAL_ERROR_CODES classification.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review: Boss Calling — part 2 (smaller items, perf, tests, style)

Correctness / clarity

  • GuiVoiceToolExecutor.kt:21-48 — two KDoc blocks are stacked, so the block written for GuiVoiceToolExecutor (in-process MCP server, embedder config, scope filtering) attaches to shouldUseFocusedPane instead, and the class ends up undocumented. Move it below the function.
  • RealtimeTransport.kt:48 — "Evictable sends always return true" isn't quite true: send() returns false for evictable frames too once !open (line 231). Harmless today since the capture callback ignores the result, but the contract as written is what the next caller will lean on.
  • Reason ordering diverges between status() and handleStart()status() is disabled → no_key → insecure_transport → not_controller (VoiceCallService.kt:154); handleStart() is not_controller → insecure_transport → disabled → no_key (line 225ff). A non-E2E controller on a keyless host is told no_key by the status push and insecure_transport on click. Cosmetic, but status()'s KDoc describes itself as "the same fact, said earlier" — the two drifting is the thing that doc exists to prevent.
  • HostVoiceCallController.start() is documented as re-entrant ("fresh audio, cleared round state, reset terminal latch", HostVoiceCallController.kt:476) but doesn't reset duplex (VoiceDuplexGate's userLevel / attenuation / learnedFrames) or userSpeechConfirmed. A redial on the same controller would inherit the previous call's echo calibration, and a call ending mid-utterance leaves userSpeechConfirmed = true. Latent only because HostVoiceCall always builds a fresh controller — which is exactly the guarantee newAudio was turned into a factory to stop depending on.
  • VoiceContextSnapshot.kt:52collapsed.take(MAX_FIELD_CHARS - 1) can split a surrogate pair, the same problem clampToolResult's takeSurrogateSafe already solves. An emoji tab title is common enough to hit it.

Performance

No concerns of substance, and several choices are right for this codebase: dedicated threads for capture and playback rather than dispatcher workers (with the IO-starvation precedent cited), HostVoiceCall.level kept off HostCallState so the 25 Hz meter only recomposes LevelMeter, per-viewer status instead of a broadcast, and the lazily-built private MCP server. Two minor notes:

  • BossCallingSection's two produceState loops re-read settings.json and voice.json every 2 s while a Share window is open, on top of the daemon's 5 s poll and MirrorShare.revocationTicks' 5 s tick per share. All behind StampCachedValue, so stats rather than parses — just several independent pollers converging on the same two files.
  • GuiVoiceToolExecutor.execute calls tools() (taking BossTermMcpServer.toolsLock, copying the disabled set) once per tool call, and VoiceCallService.handleToolCall calls executor.tools() again just before it. That lock is shared with manage_tools and the settings watcher — fine at four in-flight calls, worth remembering if the cap ever rises.

Testing

45+ new tests, aimed at the right places: the protocol state machines driven by fakes, the token gate's four failure modes, the in-flight cap admitting exactly four, the 5xx never forwarding OpenAI's text, POSIX perms on the key file, and VoiceCrossLanguageContractTest pinning the Kotlin↔JS mirrors — that last one is the right answer to a duplicated-logic problem that would otherwise rot quietly. RealtimeTransport's injected openSocket and VoiceAudioIo's injected line factories are dependencies rather than test hooks, which is the harder and better call.

Gaps are the three named in part 1: prefixed + exposeAll, manage_tools, and the live path.

Style

One broader note. VoiceCallService.kt and HostVoiceCallController.kt carry a lot of prose about bugs that no longer exist — VoiceCallService.kt:260-274 and :668-681, plus the comments narrating what earlier revisions got wrong. The best of it is load-bearing (why expireCalls is owner-filtered, why the ceilings are ordered as they are, what the mint budget does and doesn't bound) and should stay. But a reader can't easily separate "invariant you must preserve" from "development history," and the ratio makes these files harder to navigate than their logic warrants. AGENTS.md asks for comment density matching the surrounding code, and the rest of the codebase is much leaner — consider keeping the invariant and letting git history hold the narrative.


Nothing here is a reason not to ship the feature. #1 is a straightforward bug, #2 is a one-line filter plus a test, #3 is a decision already on the table, #4 needs one call by hand.

kshivang added 2 commits July 26, 2026 13:37
BossTerm's MCP surface could open tabs and splits (run_in_panel) but
never close them, so an agent asked to tidy up had to say it couldn't —
which came across as a permissions problem rather than a missing
capability. `mcp__boss__tab_close` exists, but that is the terminal-tab
plugin inside BossConsole, a different app; this server had no
counterpart.

One tool rather than two, because the caller's intent differs only by
scope and both ids come from list_panes: `pane_id` present closes that
split and leaves the rest of the tab open, absent closes the tab and
every pane in it.

It refuses a window's LAST tab. TabController.closeTab reaches
onLastTabClosed() there, whose own comment reads "exit application" — so
an agent tidying up would quit BossTerm, and over voice it would kill the
call that asked. That guard is in the tool, not in a prompt: the worst
outcome is the app vanishing, which is not something to leave to
instruction-following. It is extracted as a pure function so the rule is
actually tested; driving the handler needs a live window, and
TabbedTerminalState.createTab returns null with no PTY available.

Passing a pane_id for a tab with no splits is an error rather than a
whole-tab close: list_panes reports a split-less tab as one pane whose id
EQUALS the tab id, so an agent echoing that back is asking to close a
split it thinks exists, and treating it as "close everything" would
destroy panes on a misunderstanding.

Registered as a write tool, so allowWriteTools = false embedders don't
get it, and the in-app voice agent picks it up through the full-surface
path while shares keep the curated catalog. The voice agent gets its own
rule to name the target and confirm first, since unlike rm there is
nothing to recover.
"Keep replies short. This is a voice conversation." named a preference
without naming a behaviour, and changed nothing: the agent still restated
the request, narrated its plan, and closed by offering further help — all
around an answer that was often one word.

The rule now lists what to STOP doing (restating, narrating a plan,
explaining reasoning, listing next steps, asking if there's anything
else) and gives one-word examples of what a reply usually looks like.
It stays last in the block, because recency carries weight here.

A second rule was working against it. "Say briefly what you are about to
do before a slow tool call" invited a preamble before every call, and the
preamble was usually longer than the result. Narration is now limited to
work slow enough that silence looks broken — a build, an install — and
anything quick is just done.

The full-sentence exceptions are kept deliberately: an error the user has
to act on, and any confirmation the destructive-command and close_panel
rules require. Brevity must not eat a safety prompt.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review: Boss Calling (voice-call the session's AI agent)

I read the whole branch (72 files, ~13k lines) against master — both call surfaces, the share servers' wiring, the viewer, the MCP additions, and the test suite. This is unusually careful work: the ceiling/reclaim ladder in VoiceToolTimeouts, the two-lane outbox, the E2E gate on the ephemeral secret, the argument allowlist via declaredParameters, and the injection sanitizing in VoiceContextSnapshot all land where they need to. Test coverage is also much broader than the PR body claims (~230 tests across 23 voice files, plus the Node viewer harness actually driving the new viewer code rather than just loading it).

Below: the policy call I'd want settled before merge, four concrete defects, and some smaller notes.


1. voiceCallShareEnabled defaulting on (the open question in the description)

Agreed with your own framing, and I'd resolve it toward off. As shipped, storing a key to try the in-app call also arms voice-driven run_command for anyone already holding a control-share link. The host notification is after the fact, and RemoteVoiceCalls + the Sharing pill are the only platform-independent signal — good that they exist, but they report rather than gate.

Concretely: default voiceCallShareEnabled = false, plus a one-time host confirmation on the first remote call per share. The second half also gives you the natural home for the indirect prompt injection mitigation you flag — a host-side approval on the first write tool of a call. The instruction-level rule in builtInRules is worth its tokens, but its own comment is right that one line is not a mechanism: the agent reads curl/build-log output while holding run_command.

2. GUI share: key revocation is defeated by the || (MirrorShare.kt:156)

keyPresent = { VoiceAgentStorage.keyPresentFlow.value || keyOnDisk.get() == true },

_keyPresentFlow is only ever written by this process's save()/clear() plus the startup probe (VoiceAgentStorage.kt:52,123,132). Once it latches true, a key deleted by hand — or cleared from a second BossTerm — leaves it true forever, the || short-circuits before the disk check, and status() keeps reporting available. That is exactly the case the surrounding comment says this line fixes, and the one revocationTicks() + keyOnDisk were added for.

Consequences: viewers keep a Call button whose only outcome is a no_key round-trip, and — because closeCalls() is only reached from the voiceJob collector when hostStatus.available flips — a call already in progress keeps its tool bridge for the rest of MAX_CALL_DURATION_MS after the key was removed. The daemon path reads disk only and does revoke, so the two surfaces disagree.

Let disk be authoritative when it has an answer, flow as the fast path:

keyPresent = { keyOnDisk.get() ?: VoiceAgentStorage.keyPresentFlow.value },

This lambda has no test, which is probably why it slipped — VoiceCallServiceTest injects keyPresent directly and never exercises MirrorShare's composition of the two sources.

3. voiceExposeAllTools = true hands the in-app agent manage_tools and close_panel

GuiVoiceToolExecutor.tools()allRegisteredTools() returns the full registered surface when mayUseFocusedPane && voiceExposeAllTools (default on). That includes:

  • manage_tools, which is in UNDISABLABLE_TOOLS — so an agent talked into calling it by scrollback can re-enable a tool the user deliberately disabled and persist that to settings.json, which then flows back through server()'s disabled-set re-apply. The user has no way to keep it away from voice.
  • the new close_panel, whose own KDoc says it "kills whatever is running there and cannot be undone".

"The owner already reaches the whole MCP surface from their own endpoint" holds for reads, but the MCP endpoint isn't driven by a model that just read an attacker-influenceable build log. I'd exclude the meta/destructive tools from the voice surface even in all-tools mode (a small denylist beside LOCAL_TOOLS), or default voiceExposeAllTools off.

4. VoiceDuplexGate state crosses two threads unsynchronized

beginAgentTurn() (writes consecutive, latched) is called from onEvent on the JDK WebSocket reader thread (HostVoiceCallController.kt:508), while classify() / observeUserSpeech() read and write the same plain vars from the boss-voice-capture thread (VoiceDuplexGate.kt:100-106). Nothing is @Volatile or synchronized — out of step with the rest of this file family, where every cross-thread field is marked.

The failure mode is the one the gate exists to prevent: after a barge-in sets latched = true, the capture thread may not observe the next turn's latched = false, so classify returns PASS for that whole reply, every mic frame goes up during agent speech, and on speakers the agent interrupts itself. Cheapest fix is @Volatile on latched/consecutive, or making the three entry points @Synchronized — they run at ~25 Hz, so the monitor costs nothing.

5. Residual regex DoS in search_output (acknowledged, but worth stating plainly)

The wall-clock deadline plus SEARCH_MAX_LINE_CHARS = 8_000 bound the accidental case well, but the deadline can only be checked between findAll iterations — a catastrophic pattern against a single 8 KB line still pins the thread indefinitely inside the first match attempt (java.util.regex has no timeout). The slot reclaim in both surfaces means capacity comes back, so this is a thread leak per pathological call rather than an outage. To close it: reject patterns with nested quantifiers, or run the scan on a thread you are willing to abandon.


Smaller notes

  • GuiVoiceToolExecutor.kt:21-48 — two stacked KDoc blocks: the class-level doc (the good one, explaining the embedder-config inheritance and the local-tools split) is attached to shouldUseFocusedPane instead, so GuiVoiceToolExecutor itself ends up undocumented. Move it back onto the class.
  • VoiceAudioIopcm16Rms(chunk) is computed twice per mic frame (capture loop for the meter, then again in admit()). Passing the level into onChunk would halve it. Small, but it is per-40ms for the whole call.
  • Comment density. Several files carry more prose than code, and much of it is a changelog of superseded bugs ("this used to…", "eight tests stayed green while the fix did nothing"). The invariants are genuinely valuable and should stay — the historical narrative is the maintainability cost: a future reader has to separate current behaviour from paragraphs about behaviour that no longer exists. Where a comment documents a fixed bug, consider stating the invariant and naming the test that pins it, and letting git history hold the rest. VoiceCallService.handleStart and HostVoiceCallController.handleFunctionCall are the two densest.
  • PR body drift — test counts are stale (VoiceCallServiceTest is 38 not 17, HostVoiceCallControllerTest 23 not 8, GuiVoiceToolExecutorTest 9 not 8), and the body doesn't mention close_panel at all. A new destructive MCP tool that reaches the in-app voice agent by default deserves a line in the description — or its own PR, along with the BossTermMcpServer accessor refactor, which is independently reviewable.
  • Test gaps worth closing, given where the defects above actually live:
    • the two servers' voice wiring: per-viewer status push after a control upgrade, the confidential/callable arguments at admit, removeViewer retiring a token. The service is well covered; the composition around it — where the per-viewer-status bug the comments describe originally lived — is not.
    • MirrorShare's keyPresent lambda (item 2) and revocationTicks().
  • Merge gating — I'd hold on the "not yet verified end-to-end" caveat. The audio path, live tool calls, and the mid-call failure toasts are exactly what CI cannot reach, and HostVoiceCallControllerTest's fakes verify the protocol state machine rather than that the real session config is accepted (the session.audio.output.format.rate discovery noted in sessionUpdate is the kind of thing only a real call finds).

Nothing above is structural: items 2 and 4 are small patches, and 1 and 3 are product decisions the code is already shaped to accept either answer on. Nice work on this.

@kshivang
kshivang merged commit d04b0d5 into master Jul 26, 2026
5 checks passed
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