diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md
index 942dd4f40..9893aa1f5 100644
--- a/.papercuts/troubleshooting.md
+++ b/.papercuts/troubleshooting.md
@@ -1,5 +1,9 @@
# Troubleshooting
+- 2026-09-17 release gate: do not run `npm test` and `npm run build` concurrently in one worktree. Both compile the universal `build/native/aiden-worktree-remover`, and `lipo` races its temporary output. Run build first, then the full suite serially.
+- 2026-09-17 release gate: removing the final Live voice-approval sender left `chat:approval-withdrawn` in the preload notification allowlist. Focused Live tests do not own the global sender/allowlist equality contract; run the full suite before publishing renderer IPC changes.
+- 2026-09-17 Live motion: do not key canvas layers by caption/action state; remounting restarts the animation and causes jumps. Kept stable duplex layers and separated visual activity from microphone activity so mic-off sessions retain Stop. Production macOS package must be rebuilt separately from the HTML review bundle.
+
## 2026-09-12 — Listed upstream integration audit
- This worktree has no `.memory/` or `node_modules/`. Read the main checkout's project memory as historical context, but use this worktree's exact HEAD/source as authority. The main checkout's `tsx` binary can execute dependency-free focused suites without installing packages here; suites with runtime package imports still fail module resolution (observed: `entities` in the subagent capability suite). Treat that as an environment limitation, not a product regression or passing test.
@@ -505,7 +509,19 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed.
- The installed Google SDK exposes Live `interactionStatus` inside `serverContent`, while newer Extended Thinking examples describe status alongside tool-call lifecycle events; pin protocol handling to the installed typed wire contract and cover `IDLE` explicitly when adopting the new model.
# Release coordination
+- Screen-share work was based on an older Live contract. Integration must retain the exact Computer Use authorization token, extended-thinking model, async thread finalization, direct-action policy, device routing, and empty-envelope fix; add screen intent without replacing these later changes. Original screen worktree remains unchanged.
+
+- Live device picker visual check found two interacting issues: settings action-cluster CSS wraps every direct `.flex` child, including combobox triggers, and Radix Select.Value strips className/style. Scope a no-wrap override to Live device triggers and truncate their actual value spans; static markup tests alone did not catch geometry.
+
+- Live device routing must retain one audio dependency/player instance across capability refresh; otherwise start preflight can configure a different player from the hook's retained playback ref. Keep capabilities separate from audio ownership.
+
+- Audio selectors: Radix SelectValue has no selected-item text in static rendering until its item collection mounts; supply an explicit selected label so unavailable-device and initial-render states are readable and testable.
+
- Pullfrog took just over one hour to review PR #132 after first-party CI was green; keep exact-head checks separate so the long external review does not obscure test status.
- Moving Live from default-on to acceptance-gated correctly hid the dock but invalidated the local UI E2E; keep default-off rendering covered separately and opt the isolated provider-free E2E harness into the experimental surface explicitly.
- The hosted full Electron suite marked the unrelated chat-switch queue test flaky after it passed on retry, and `--fail-on-flaky-tests` failed the whole gate; rerun the exact failed job before changing unrelated product behavior, while preserving the strict gate if the flake repeats.
- A distant Environment source-contract test asserted Aiden Live's two-argument command registration, so focused Live tests missed the intentional capability gate; search all source-contract assertions when changing a shared command signature.
+- Signed Live test build: `isPackagedRuntime()` is false for an isolated development profile, causing Computer Use to search inside app.asar/build instead of Contents/Helpers. Resolve physical helper layout using `app.isPackaged`; profile identity must not determine package resource locations.
+- Live cue tests must flush passive React effects after microphone and stop state updates before asserting audio feedback. The repo has no local Prettier binary; use its configured ESLint validation instead.
+- Real signed Live session reached open/microphone-ready/input-first-packet, then Google emitted an empty top-level envelope at 00:20:01 UTC on 2026-09-17. Rejecting `{}` caused the observed silent disconnect. Admit exact empty envelopes as rate-limited no-ops without extending idle timeout; keep unknown populated fields rejected. Terminal error HUDs must remain visible after active becomes false.
+- The Mac's default input was Bose Mini II while output was MacBook speakers. Packet flow alone does not prove intelligible user speech or audible playback; retain separate operator verification.
diff --git a/docs/plans/README.md b/docs/plans/README.md
index e430352f0..23e7f9acb 100644
--- a/docs/plans/README.md
+++ b/docs/plans/README.md
@@ -21,7 +21,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi
| [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Implemented | Validated pi.dev overlays, offline `0600` cache hydration, scoped setup refresh, four-hour launch refresh, force refresh, Pi metadata fallback, and Mac/iOS projection ship on pinned Pi 0.84.4. |
| [Generative UI Artifacts](generative-ui-artifacts-plan.md) | Active | Phases 0–6 shipped: chat-scoped `render_artifact`, strict sandboxed preview/export hosts, verified vendored Chart.js/Plotly/KaTeX, permission-aware `/visualize`, crash-recoverable authoritative storage/copies, descriptor-relative workspace reads, one-iframe handoff/expansion, visible failure states, and route-stable Responding/Visualizing activity. Three-agent PR review findings are remediated with focused regression coverage. |
| [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. |
-| [Aiden Live Assistant](gemini-live-assistant-plan.md) | Partial | Phases 0–4, Extended Thinking spoken progress, strict voice-only per-action approval, the chrome-free blue orb, Google model visibility controls, and metadata-only per-session threads are implemented; native screen-picker and real-Google operator receipts remain open. |
+| [Aiden Live Assistant](gemini-live-assistant-plan.md) | Partial | Extended Thinking, the blue orb, Google model visibility, and session threads are implemented. Local test builds add input/output device selection, connection sounds, and session-authorized direct actions without per-action prompts; native screen-picker and real-Google operator receipts remain open. |
| [Libghostty workspace terminal](libghostty-terminal-plan.md) | Implemented | The workspace drawer uses Ghostty's official `libghostty-vt` WASM (T3-style runtime, PTY trampoline, canvas surface); node-pty sessions are unchanged. Packaged Mac acceptance remains. |
| [Logging and Diagnostics Upgrade](logging-and-diagnostics-upgrade-plan.md) | Implemented | Phases 0–7 are implemented: bounded typed desktop journals, main-owned renderer evidence, local support export/delete, native categorical parity, and CI/release gates. Signed/notarized `v0.35.0` passed packaged diagnostics acceptance; physical-device termination receipts remain. |
| [Long-thread payload upgrades](long-thread-payload-upgrade-plan.md) | Partial | Investigation complete: T3’s O(N²) stdout store does not exist here. No-op `toolRunning` timeline republish is skipped; Remote gzip, stream-journal debounce, chat JSON/attachments, and transcript windowing remain planned. |
diff --git a/docs/plans/gemini-live-assistant-plan.md b/docs/plans/gemini-live-assistant-plan.md
index 7c1fc2825..f9f60cccd 100644
--- a/docs/plans/gemini-live-assistant-plan.md
+++ b/docs/plans/gemini-live-assistant-plan.md
@@ -2,6 +2,13 @@
Status: Partial — Phases 0–4 plus the beta-labeled Aiden Live orb/setup shell and dedicated metadata-only session threads are implemented; authorized macOS screen capture and real Google beta receipts remain operator-owned
Date: 2026-09-15
+Continuous orb follow-up (2026-09-17): active listening/thinking/speaking/acting now share stable Listening + Weaving canvas layers with a slow complementary blend and connection/rest crossfades. Removed visible dock status text (retained screen-reader status). First active click reveals Stop; the second stops, Escape restores the orb, and closing disables duplicate clicks. Sharing/error surfaces remain visible. Added mapping/click-policy regressions; all 230 Live tests, type-check, lint, and standalone browser interaction/motion checks pass. Downloads duplex-review.html uses the actual orb component; the installed notarized app has not been rebuilt for this change.
+Combined artifact verification (2026-09-17): Apple accepted notarization submission `5b7f74d8-4740-47b9-82d8-66704cb75ec7`; the Downloads Combined Notarized app has a stapled ticket and passes the repository notarized-package/Gatekeeper check. Opened with the preserved isolated Test Profile and verified its renderer and Start Aiden Live control. Full Live suites, type-check, lint, audio/settings tests, and Electron selector-layout regression passed. Native-picker and real-provider acceptance remain separate and pending.
+Combined build (2026-09-17): integrated the screen-sharing work from the 2f30 worktree into this audio-fix checkout without changing the source worktree. Preserved extended-thinking model selection, dedicated session threads, async thread finalization, direct actions, diagnostic markers, empty-envelope handling, device routing, and dropdown layout. Screen capture remains separately gated and off in the ordinary test launcher pending the exact-build native-picker acceptance receipt. Notarization and attended acceptance are separate gates; this is not a public release.
+Audio selector layout follow-up: scoped single-line trigger styling and actual Radix value-span ellipsis fix long device labels pushing chevrons outside the control. Real Electron geometry regression passes at 1280, 600, and 390px; the full label remains available through the dropdown and hover title.
+Audio selection (2026-09-16 local): Settings → Aiden Live now offers device-local input/output preferences, system defaults, hotplug list refresh, and a speaker test. Selections are snapshotted per session; explicit microphone constraints and output sink routing apply to voice capture, replies, and both cues. Missing selections fail with recovery instructions instead of silent startup fallback. Setup points users to these settings. The shared audio player remains stable across capability refresh; preparation is cancellation-fenced. Tests extend the existing registered Live/UI/audio suites.
+Runtime diagnosis (2026-09-16 local): signed Live reached Google/open and microphone packet flow, but an empty top-level server envelope caused a fatal parser error. Exact empty envelopes now count against rate limits without extending idle liveness. Fixed lifecycle/audio/cue markers enter the local diagnostic journal without media/transcripts/credentials. The orb now labels microphone state, preserves terminal errors, and uses more audible connection tones. The signed Audio Fix build subsequently connected, captured microphone packets, received Google response audio, started playback, and stayed connected for 54 seconds until manually stopped. Both cue markers and the listening/idle labels were verified. Physical audibility still needs user confirmation; Computer Use acceptance remains unclaimed. Relevant tests, type-check, lint, and hardened development package verification passed.
+Local test-build update (2026-09-16): signed development profiles resolve the bundled Computer Use helper by physical package layout. Live plays connection/disconnection cues. At the user's explicit request, starting Live with Computer Use enabled authorizes direct actions for that session without per-action prompts or voice approvals. The dock no longer mounts the voice-approval listener. Target binding, current capture requirements, cancellation, owner identity, and enablement checks remain enforced. Historical per-action approval sections below describe the superseded implementation; ordinary chat policy is unchanged. No new real-provider acceptance is claimed.
Related: `aiden-assistant-plan.md`, `pi-provider-integration-plan.md`, and
`../computer-use-integration.md`
@@ -11,9 +18,9 @@ The bottom-right **Aiden orb** becomes the single entry point for a
user-started Aiden Live session. Aiden streams microphone PCM and a
user-approved screen/window as bounded JPEG frames to Gemini Live, plays Gemini
native audio, and shows its input/output captions. When Gemini needs to act, it
-invokes Aiden's existing `computer_use` tool; every input action keeps the
-current global gate, per-chat activation, target binding, and fresh **Allow
-once** approval.
+invokes Aiden's existing `computer_use` tool. During Live, actions execute under
+the user's session consent with global enablement and exact target binding.
+Stopping Live revokes this authority. Ordinary chats retain their own policy.
This is deliberately not a general screen recorder, an unattended assistant,
or a second automation authority.
diff --git a/main/handlers/assistant-live-parse.test.ts b/main/handlers/assistant-live-parse.test.ts
index 1bf724ada..28d80b662 100644
--- a/main/handlers/assistant-live-parse.test.ts
+++ b/main/handlers/assistant-live-parse.test.ts
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
+import { GEMINI_LIVE_MAX_JPEG_BYTES } from "../services/gemini-live/protocol.js";
import {
parseAssistantLiveAudioIntent,
+ parseAssistantLiveEmptyIntent,
+ parseAssistantLiveFrameIntent,
parseAssistantLiveStartIntent,
parseAssistantLiveStopIntent,
} from "./assistant-live-parse.js";
@@ -21,6 +24,9 @@ test("Assistant Live audio admission accepts only one exact 20 ms PCM chunk", ()
});
test("Assistant Live start intent accepts only an exact bounded authorization record", () => {
+ assert.equal(parseAssistantLiveStartIntent({ microphone: true, screen: true, computerUseAuthorization: null }).screen, true);
+ assert.throws(() => parseAssistantLiveStartIntent({ microphone: true, screen: "true", computerUseAuthorization: null }));
+ assert.throws(() => parseAssistantLiveStartIntent({ microphone: true, screen: true }));
assert.deepEqual(parseAssistantLiveStartIntent({ microphone: true, computerUseAuthorization: null }), {
microphone: true,
computerUseAuthorization: null,
@@ -51,3 +57,33 @@ test("Assistant Live stop intent accepts only an exact empty record", () => {
/Invalid Assistant Live/u,
);
});
+
+test("Assistant Live frame admission accepts only one bounded copied JPEG byte range", () => {
+ const frame = new Uint8Array([0xff, 0xd8, 0x2a, 0xff, 0xd9]);
+ const parsed = parseAssistantLiveFrameIntent({ sessionId: "session-1", frame });
+ assert.equal(parsed.sessionId, "session-1");
+ assert.deepEqual(parsed.frame, frame);
+ assert.notEqual(parsed.frame, frame, "the parser copies admitted bytes");
+ for (const value of [
+ { sessionId: "session-1", frame: new Uint8Array(3) },
+ { sessionId: "session-1", frame: new Uint8Array(GEMINI_LIVE_MAX_JPEG_BYTES + 1) },
+ { sessionId: "", frame },
+ { sessionId: "session-1", frame: frame.buffer },
+ { sessionId: "session-1", frame, apiKey: "secret" },
+ { frame },
+ ]) assert.throws(() => parseAssistantLiveFrameIntent(value), /frame request/u);
+});
+
+test("Assistant Live display bind/release accept only an exact empty record", () => {
+ for (const name of ["display-bind", "display-release"]) {
+ assert.doesNotThrow(() => parseAssistantLiveEmptyIntent({}, name));
+ assert.throws(
+ () => parseAssistantLiveEmptyIntent({ sessionId: "forged" }, name),
+ new RegExp(`Invalid Assistant Live ${name}`, "u"),
+ );
+ assert.throws(
+ () => parseAssistantLiveEmptyIntent(null, name),
+ new RegExp(`Invalid Assistant Live ${name}`, "u"),
+ );
+ }
+});
diff --git a/main/handlers/assistant-live-parse.ts b/main/handlers/assistant-live-parse.ts
index 210799592..11bcb2c86 100644
--- a/main/handlers/assistant-live-parse.ts
+++ b/main/handlers/assistant-live-parse.ts
@@ -1,4 +1,5 @@
import type { AssistantLiveStartIntent } from "../../renderer/shared/assistant-live.js";
+import { GEMINI_LIVE_MAX_JPEG_BYTES } from "../services/gemini-live/protocol.js";
function isRecord(value: unknown): value is Record {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -12,7 +13,8 @@ function exactKeys(value: Record, expected: readonly string[]):
export function parseAssistantLiveStartIntent(value: unknown): AssistantLiveStartIntent {
if (
!isRecord(value) ||
- !exactKeys(value, ["computerUseAuthorization", "microphone"]) ||
+ !exactKeys(value, "screen" in value ? ["computerUseAuthorization", "microphone", "screen"] : ["computerUseAuthorization", "microphone"]) ||
+ ("screen" in value && typeof value.screen !== "boolean") ||
typeof value.microphone !== "boolean" ||
!(
value.computerUseAuthorization === null ||
@@ -25,6 +27,7 @@ export function parseAssistantLiveStartIntent(value: unknown): AssistantLiveStar
}
return {
microphone: value.microphone,
+ ...("screen" in value ? { screen: value.screen as boolean } : {}),
computerUseAuthorization: value.computerUseAuthorization,
};
}
@@ -54,3 +57,33 @@ export function parseAssistantLiveAudioIntent(value: unknown): AssistantLiveAudi
}
return { sessionId: value.sessionId, pcm: Uint8Array.from(value.pcm) };
}
+
+export interface AssistantLiveFrameIntent {
+ sessionId: string;
+ frame: Uint8Array;
+}
+
+export function parseAssistantLiveFrameIntent(value: unknown): AssistantLiveFrameIntent {
+ if (
+ !isRecord(value) ||
+ !exactKeys(value, ["frame", "sessionId"]) ||
+ typeof value.sessionId !== "string" ||
+ value.sessionId.length < 1 ||
+ value.sessionId.length > 128 ||
+ !(value.frame instanceof Uint8Array) ||
+ value.frame.byteLength < 4 ||
+ value.frame.byteLength > GEMINI_LIVE_MAX_JPEG_BYTES
+ ) {
+ throw new Error("Invalid Assistant Live frame request.");
+ }
+ return { sessionId: value.sessionId, frame: Uint8Array.from(value.frame) };
+}
+
+export function parseAssistantLiveEmptyIntent(
+ value: unknown,
+ name: string,
+): void {
+ if (!isRecord(value) || !exactKeys(value, [])) {
+ throw new Error(`Invalid Assistant Live ${name} request.`);
+ }
+}
diff --git a/main/handlers/assistant-live.ts b/main/handlers/assistant-live.ts
index 6ae585373..8a27d8116 100644
--- a/main/handlers/assistant-live.ts
+++ b/main/handlers/assistant-live.ts
@@ -1,12 +1,19 @@
+import { session } from "electron";
import { ipcMain } from "../platform.js";
import {
authorizeAidenLiveComputerUse,
geminiLiveService,
} from "../services/gemini-live/service-main.js";
import { rendererDocumentOwner } from "../services/renderer-document-owner.js";
+import {
+ bindGeminiLiveDisplayMediaDocument,
+ installGeminiLiveDisplayMediaGuards,
+} from "../services/gemini-live/display-media-contract.js";
import {
parseAssistantLiveStartIntent,
parseAssistantLiveAudioIntent,
+ parseAssistantLiveEmptyIntent,
+ parseAssistantLiveFrameIntent,
parseAssistantLiveStopIntent,
} from "./assistant-live-parse.js";
import { invokeAssistantLiveStart } from "./assistant-live-start.js";
@@ -43,4 +50,24 @@ export function registerAssistantLiveHandlers(): void {
const intent = parseAssistantLiveAudioIntent(input);
return geminiLiveService.sendAudio(owner(event), intent.sessionId, intent.pcm);
});
+ ipcMain.handle("assistant-live:display-bind", (event, input: unknown) => {
+ parseAssistantLiveEmptyIntent(input, "display-bind");
+ const binding = bindGeminiLiveDisplayMediaDocument(event);
+ if (!geminiLiveService.bindDisplayMedia(owner(event), binding)) return false;
+ // The guards consult the live binding set, so install them only after this
+ // document's binding is registered and only once per Electron session.
+ installGeminiLiveDisplayMediaGuards(session.defaultSession, () =>
+ geminiLiveService.displayMediaBindings(),
+ );
+ return true;
+ });
+ ipcMain.handle("assistant-live:display-release", (event, input: unknown) => {
+ parseAssistantLiveEmptyIntent(input, "display-release");
+ geminiLiveService.releaseDisplayMedia(owner(event));
+ return true;
+ });
+ ipcMain.handle("assistant-live:frame", (event, input: unknown) => {
+ const intent = parseAssistantLiveFrameIntent(input);
+ return geminiLiveService.sendFrame(owner(event), intent.sessionId, intent.frame);
+ });
}
diff --git a/main/index.ts b/main/index.ts
index 89f744277..ec291acb7 100644
--- a/main/index.ts
+++ b/main/index.ts
@@ -1290,6 +1290,13 @@ async function createMainWindow(): Promise {
createdWindow.webContents.on("did-finish-load", () => {
protectedAction = null;
});
+ let liveDiagnosticCount = 0;
+ createdWindow.webContents.on("console-message", (details) => {
+ // Only fixed local lifecycle markers; never forward arbitrary renderer console content.
+ if (liveDiagnosticCount >= 200 || !/^\[aiden-live\] (microphone-ready|input-first-packet|output-first-packet|playback-started|playback-failed|cue-connected|cue-disconnected|cue-failed)$/.test(details.message)) return;
+ liveDiagnosticCount += 1;
+ writeDiagnosticEvent({ level: "info", area: "voice", event: "legacy-log", fields: { message: details.message } });
+ });
createdWindow.webContents.setWindowOpenHandler(({ url }) => {
openExternalUrl(url);
diff --git a/main/services/computer-use/computer-use-foundation.test.ts b/main/services/computer-use/computer-use-foundation.test.ts
index a841d57e9..1bc1a8da3 100644
--- a/main/services/computer-use/computer-use-foundation.test.ts
+++ b/main/services/computer-use/computer-use-foundation.test.ts
@@ -30,6 +30,12 @@ import {
} from "./session.js";
const fixture = fileURLToPath(new URL("./fixtures/fake-cua-driver.mjs", import.meta.url));
+
+test("Computer Use helper layout follows physical packaging, not the runtime profile", async () => {
+ const source = await readFile(new URL("./runtime.ts", import.meta.url), "utf8");
+ assert.match(source, /isPackaged:\s*app\.isPackaged/);
+ assert.doesNotMatch(source, /isPackagedRuntime/);
+});
const SESSION_LESS_TOOLS = new Set([
"health_report",
"check_permissions",
diff --git a/main/services/computer-use/runtime.ts b/main/services/computer-use/runtime.ts
index b89dcf03a..2536f0e7c 100644
--- a/main/services/computer-use/runtime.ts
+++ b/main/services/computer-use/runtime.ts
@@ -1,5 +1,4 @@
import { app } from "../../platform.js";
-import { isPackagedRuntime } from "../../runtime-mode.js";
import { resolveCuaDriverInstallation } from "./binary.js";
import { ComputerUseController } from "./controller.js";
import { CuaDriverHost } from "./host.js";
@@ -21,7 +20,8 @@ export async function createCuaDriverHost(signal: AbortSignal): Promise boolean | Promise;
approve?: (signal: AbortSignal, summary: string) => Promise;
@@ -83,6 +84,7 @@ function harness(overrides: {
const approvalSummaries: string[] = [];
const bridge = new GeminiLiveComputerUseBridge({
sessionId: "session-1",
+ actionPolicy: overrides.actionPolicy,
controller,
isAuthorized: overrides.authorized ?? (() => true),
requestApproval: ({ signal, summary }) => {
@@ -94,6 +96,26 @@ function harness(overrides: {
return { bridge, controller, responses, approvalSummaries };
}
+test("session actions execute without prompts and still bind a fresh target grant", async () => {
+ const h = harness({ actionPolicy: "session", approve: async () => { throw new Error("must not prompt"); } });
+ h.bridge.enqueue({ id: "type", name: "computer_use", args: { action: "type", text: "hello" } });
+ await tick();
+ assert.deepEqual(h.approvalSummaries, []);
+ assert.deepEqual(h.controller.authorizations, ["type"]);
+ assert.deepEqual(h.controller.executions, ["type"]);
+ await h.bridge.close();
+});
+
+test("session actions recheck revoked authority after resolving the target", async () => {
+ let checks = 0;
+ const h = harness({ actionPolicy: "session", authorized: () => ++checks === 1 });
+ h.bridge.enqueue({ id: "type", name: "computer_use", args: { action: "type", text: "hello" } });
+ await tick();
+ assert.deepEqual(h.controller.authorizations, []);
+ assert.deepEqual(h.controller.executions, []);
+ await h.bridge.close();
+});
+
test("Live bridge rejects unknown tools and fields before the controller", async () => {
const h = harness();
h.bridge.enqueue({ id: "wrong", name: "shell", args: {} });
diff --git a/main/services/gemini-live/computer-use-bridge.ts b/main/services/gemini-live/computer-use-bridge.ts
index 4164c88a3..db1f602a0 100644
--- a/main/services/gemini-live/computer-use-bridge.ts
+++ b/main/services/gemini-live/computer-use-bridge.ts
@@ -34,6 +34,8 @@ export interface GeminiLiveComputerUseCall {
export interface GeminiLiveComputerUseBridgeOptions {
sessionId: string;
+ /** Explicit Live-session consent replaces per-action prompts; target checks still apply. */
+ actionPolicy?: "session" | "per-action";
controller: GeminiLiveComputerUseController;
isAuthorized(): boolean | Promise;
requestApproval(input: {
@@ -205,7 +207,7 @@ export class GeminiLiveComputerUseBridge {
entry.controller.signal,
);
if (approval) {
- const allowed = await this.options.requestApproval({
+ const allowed = this.options.actionPolicy === "session" || await this.options.requestApproval({
streamId: `live:${this.options.sessionId}`,
toolCallId: entry.call.id,
toolName: COMPUTER_USE_TOOL_NAME,
diff --git a/main/services/gemini-live/display-media-contract.test.ts b/main/services/gemini-live/display-media-contract.test.ts
index ad1049399..7e9482899 100644
--- a/main/services/gemini-live/display-media-contract.test.ts
+++ b/main/services/gemini-live/display-media-contract.test.ts
@@ -4,6 +4,8 @@ import test from "node:test";
import {
GEMINI_LIVE_SYSTEM_PICKER_OPTIONS,
bindGeminiLiveDisplayMediaDocument,
+ installGeminiLiveDisplayMediaGuards,
+ type GeminiLiveDisplayMediaBinding,
} from "./display-media-contract.js";
class FakeFrame {
@@ -106,6 +108,126 @@ test("binds both custom-picker and system-picker permission admission to one exa
);
});
+test("session guards gate display-capture only and install exactly once", () => {
+ const installed = {
+ checks: 0,
+ requests: 0,
+ displays: 0,
+ check: null as
+ | ((
+ webContents: Electron.WebContents | null,
+ permission: string,
+ requestingOrigin: string,
+ details: Electron.PermissionCheckHandlerHandlerDetails,
+ ) => boolean)
+ | null,
+ request: null as
+ | ((
+ webContents: Electron.WebContents,
+ permission: string,
+ callback: (granted: boolean) => void,
+ details: Electron.PermissionRequest,
+ ) => void)
+ | null,
+ display: null as
+ | ((
+ request: Electron.DisplayMediaRequestHandlerHandlerRequest,
+ callback: (streams: Electron.Streams) => void,
+ ) => void)
+ | null,
+ opts: undefined as Electron.DisplayMediaRequestHandlerOpts | undefined,
+ };
+ const electronSession = {
+ setPermissionCheckHandler(handler: typeof installed.check) {
+ installed.checks += 1;
+ installed.check = handler;
+ },
+ setPermissionRequestHandler(handler: typeof installed.request) {
+ installed.requests += 1;
+ installed.request = handler;
+ },
+ setDisplayMediaRequestHandler(
+ handler: typeof installed.display,
+ opts?: Electron.DisplayMediaRequestHandlerOpts,
+ ) {
+ installed.displays += 1;
+ installed.display = handler;
+ installed.opts = opts;
+ },
+ };
+ const frame = new FakeFrame(10, 20, "document-one", "file:///Aiden/main-window.html");
+ const sender = new FakeWebContents(7, frame);
+ const bindings: GeminiLiveDisplayMediaBinding[] = [];
+ installGeminiLiveDisplayMediaGuards(electronSession, () => bindings);
+ installGeminiLiveDisplayMediaGuards(electronSession, () => bindings);
+ assert.equal(installed.checks, 1, "re-installation must not stack handlers");
+ assert.equal(installed.requests, 1);
+ assert.equal(installed.displays, 1);
+ assert.deepEqual(installed.opts, { useSystemPicker: true });
+
+ const details = { isMainFrame: true, requestingUrl: frame.url } as Electron.PermissionRequest;
+ // Without a Live binding every display-capture path denies.
+ assert.equal(
+ installed.check?.(
+ sender as unknown as Electron.WebContents,
+ "display-capture",
+ "file:///Aiden/",
+ details,
+ ),
+ false,
+ );
+ let granted: boolean | null = null;
+ installed.request?.(
+ sender as unknown as Electron.WebContents,
+ "display-capture",
+ (next) => {
+ granted = next;
+ },
+ details,
+ );
+ assert.equal(granted, false);
+
+ // A bound document admits display-capture; every other permission keeps the
+ // session's default allow so the guards never shrink unrelated authority.
+ bindings.push(bindGeminiLiveDisplayMediaDocument(invokeEvent(sender, frame)));
+ assert.equal(
+ installed.check?.(
+ sender as unknown as Electron.WebContents,
+ "display-capture",
+ "file:///Aiden/",
+ details,
+ ),
+ true,
+ );
+ assert.equal(
+ installed.check?.(null, "media", "file:///Aiden/", details),
+ true,
+ "non-display permissions keep the default policy",
+ );
+ granted = null;
+ installed.request?.(sender as unknown as Electron.WebContents, "media", (next) => {
+ granted = next;
+ }, details);
+ assert.equal(granted, true);
+
+ // Any non-picker dispatch to the fallback handler is denied outright.
+ const streams: Electron.Streams[] = [];
+ installed.display?.(displayRequest(frame), (next) => streams.push(next));
+ assert.deepEqual(streams, [{}]);
+
+ // Releasing the binding closes capture again immediately.
+ bindings.pop();
+ assert.equal(
+ installed.check?.(
+ sender as unknown as Electron.WebContents,
+ "display-capture",
+ "file:///Aiden/",
+ details,
+ ),
+ false,
+ );
+});
+
test("navigation, replacement frames, and unrelated WebContents fail closed", () => {
const frame = new FakeFrame(10, 20, "document-one", "file:///Aiden/main-window.html");
const sender = new FakeWebContents(7, frame);
diff --git a/main/services/gemini-live/display-media-contract.ts b/main/services/gemini-live/display-media-contract.ts
index af903e38c..bf5d9b678 100644
--- a/main/services/gemini-live/display-media-contract.ts
+++ b/main/services/gemini-live/display-media-contract.ts
@@ -86,3 +86,81 @@ export function bindGeminiLiveDisplayMediaDocument(
details.requestingUrl === requestingUrl,
};
}
+
+interface DisplayMediaGuardSession {
+ setPermissionCheckHandler(
+ handler: ((
+ webContents: Electron.WebContents | null,
+ permission: string,
+ requestingOrigin: string,
+ details: Electron.PermissionCheckHandlerHandlerDetails,
+ ) => boolean) | null,
+ ): void;
+ setPermissionRequestHandler(
+ handler: ((
+ webContents: Electron.WebContents,
+ permission: string,
+ callback: (permissionGranted: boolean) => void,
+ details: Electron.PermissionRequest,
+ ) => void) | null,
+ ): void;
+ setDisplayMediaRequestHandler(
+ handler:
+ | ((
+ request: Electron.DisplayMediaRequestHandlerHandlerRequest,
+ callback: (streams: Electron.Streams) => void,
+ ) => void)
+ | null,
+ opts?: Electron.DisplayMediaRequestHandlerOpts,
+ ): void;
+}
+
+const guardedSessions = new WeakSet
{orbState === "approval" ? Approval needed : null}
+ {live.screenActive ? (
+
+
+ Sharing {live.screenSourceLabel ?? "screen"}
+
+ ) : null}
Google Gemini{live.model ? ` · ${live.model}` : ""}
diff --git a/renderer/components/assistant/assistant-ui.test.tsx b/renderer/components/assistant/assistant-ui.test.tsx
index eb1982cea..d89d1080d 100644
--- a/renderer/components/assistant/assistant-ui.test.tsx
+++ b/renderer/components/assistant/assistant-ui.test.tsx
@@ -1,8 +1,24 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
+import { LiveAudioDeviceFields } from "../settings/live-audio-settings.js";
+
+test("Live settings expose labeled input and output selectors with system defaults", () => {
+ const html = renderToStaticMarkup( undefined} />);
+ assert.match(html, /Live input device/);
+ assert.match(html, /Live output device/);
+ assert.match(html, /System default/);
+ assert.match(html, /replies and start\/stop sounds/);
+});
+
+test("Live audio player ownership survives asynchronous capability refresh", () => {
+ const source = readFileSync(new URL("./use-assistant-live.ts", import.meta.url), "utf8");
+ assert.match(source, /\[audioDependencies\] = React\.useState\(\(\) => defaultDependencies\(false\)\)/);
+ assert.match(source, /\.\.\.audioDependencies,\s+geminiLive,/);
+});
import { renderToStaticMarkup } from "react-dom/server";
-import { AssistantDockPresentation } from "./assistant-dock.js";
+import { AssistantDockPresentation, liveDockClickAction } from "./assistant-dock.js";
+import { aidenLiveOrbVisual, AidenLiveOrb } from "./aiden-live-orb.js";
import { assistantLiveOrbState, assistantLiveTranscriptFollowsLatest } from "./assistant-live.js";
import {
assistantLiveVoiceApprovalDecision,
@@ -40,17 +56,49 @@ const idleLive: AssistantLiveController = {
computerUseDetail: "Ready",
computerUsePermissions: { accessibility: true, screenRecording: true },
computerUseError: null,
+ screenShareAvailable: false,
+ screenSourceLabel: null,
+ screenActive: false,
+ screenBusy: false,
+ screenError: null,
setupComplete: true,
setSetupOpen: () => undefined,
setMicrophone: () => undefined,
setComputerUse: async () => undefined,
requestMicrophonePermission: async () => true,
prepareComputerUse: async () => undefined,
+ chooseScreenSource: async () => undefined,
+ releaseScreen: () => undefined,
start: async () => undefined,
stop: async () => undefined,
cancelSetup: async () => undefined,
};
+test("duplex visuals remain stable through voice and action changes", () => {
+ for (const state of ["listening", "thinking", "speaking", "acting"] as const) {
+ assert.equal(aidenLiveOrbVisual(state), "duplex");
+ const markup = renderToStaticMarkup();
+ assert.match(markup, /data-visual="duplex"/);
+ assert.doesNotMatch(markup, /NaN/);
+ }
+ assert.equal(aidenLiveOrbVisual("error"), "rest");
+ assert.equal(aidenLiveOrbVisual("connecting"), "connecting");
+ assert.equal(assistantLiveOrbState({ ...idleLive, active: true, state: "open", microphoneActive: false }), "listening");
+ assert.equal(assistantLiveOrbState({ ...idleLive, active: true, busy: true, state: "closing" }), "ready");
+});
+
+test("dock requires reveal then stop, including mic-off and first-session startup", () => {
+ const active = { ...idleLive, active: true, state: "open" as const };
+ assert.equal(liveDockClickAction(active, true, false), "reveal-stop");
+ assert.equal(liveDockClickAction(active, true, true), "stop");
+ assert.equal(liveDockClickAction(active, false, false), "reveal-stop");
+ assert.equal(liveDockClickAction({ ...active, state: "closing", busy: true }, true, true), "none");
+ assert.equal(liveDockClickAction({ ...idleLive, busy: true }, true, false), "none");
+ assert.equal(liveDockClickAction(idleLive, true, false), "start");
+ assert.equal(liveDockClickAction(idleLive, false, false), "setup");
+ assert.equal(liveDockClickAction({ ...idleLive, setupComplete: false }, true, false), "settings");
+});
+
test("Aiden Live orb state prioritizes errors, approvals, and connection work", () => {
assert.equal(assistantLiveOrbState(idleLive), "ready");
assert.equal(
@@ -164,7 +212,8 @@ test("dock replaces the retired Assistant panel with setup logo then Live orb",
assert.match(dock, /AssistantLiveSetupDialog/u);
assert.match(dock, /AidenLiveOrb/u);
assert.match(dock, /AssistantComputerUseApproval/u);
- assert.match(dock, /live\.latestVoiceApprovalReceiptId/u);
+ assert.doesNotMatch(dock, /useAssistantLiveApprovals\(/u);
+ assert.match(dock, /chat=\{SESSION_ACTIONS\}/u);
assert.match(dock, /useCommand\("assistant\.open", openPanel, live\.visible\)/u);
assert.doesNotMatch(dock, /onDecision=/u);
assert.doesNotMatch(dock, /useAssistantChat/u);
@@ -186,7 +235,20 @@ test("a disabled Live capability renders no dock or setup affordance", () => {
assert.equal(commandEnabled, false);
});
-test("Computer Use approvals are voice-only and keep the exact action visible", () => {
+test("a disconnected Live error stays visible and microphone status is screen-reader-only", () => {
+ const render = (live: AssistantLiveController) => renderToStaticMarkup(
+ undefined }}
+ live={live}
+ useCommand={() => undefined}
+ />,
+ );
+ assert.match(render({ ...idleLive, state: "failed", error: "The Live provider sent an invalid event." }), /The Live provider sent an invalid event\./);
+ assert.match(render({ ...idleLive, state: "open", active: true, microphoneActive: true }), /Listening · mic on/);
+ assert.match(render({ ...idleLive, state: "open", active: true, microphoneActive: true }), /class="sr-only"/);
+});
+
+test("legacy voice approval presentation remains isolated from the direct-action dock", () => {
const approval = readFileSync(
new URL("./assistant-computer-use-approval.tsx", import.meta.url),
"utf8",
@@ -200,16 +262,26 @@ test("Computer Use approvals are voice-only and keep the exact action visible",
assert.match(hook, /consumedReceiptIds/u);
});
-test("setup discloses macOS access and per-action approval", () => {
+test("setup discloses macOS access and direct actions until Stop", () => {
const live = readFileSync(new URL("./assistant-live.tsx", import.meta.url), "utf8");
assert.match(live, /Google Live model/u);
assert.match(live, /Screen and Accessibility/u);
assert.match(live, /Scheduled tasks/u);
- assert.match(live, /still require Allow once/u);
+ assert.match(live, /without per-action prompts until you stop/u);
assert.match(live, /role="log"/u);
assert.match(live, /aria-live="polite"/u);
});
+test("screen sharing is opt-in, source-labelled, and visibly active in the HUD", () => {
+ const live = readFileSync(new URL("./assistant-live.tsx", import.meta.url), "utf8");
+ assert.match(live, /live\.screenShareAvailable/u);
+ assert.match(live, /title="Screen share"/u);
+ assert.match(live, /live\.chooseScreenSource/u);
+ assert.match(live, /live\.releaseScreen/u);
+ assert.match(live, /Sharing \{live\.screenSourceLabel \?\? "screen"\}/u);
+ assert.match(live, /role="alert"[\s\S]*live\.screenError/u);
+});
+
test("Aiden Live is visibly marked beta in setup and settings", () => {
const setup = readFileSync(new URL("./assistant-live.tsx", import.meta.url), "utf8");
const settings = readFileSync(
@@ -239,7 +311,8 @@ test("the Live orb maps all user-visible states onto the shared Libraries.dev or
assert.match(orb, new RegExp(`\\b${state}\\b`, "u"));
}
assert.match(orb, /import \{ AidenOrb \} from "\.\.\/aiden-orb"/u);
- assert.match(orb, /listening: \{ state: "breathing", active: true \}/u);
+ assert.match(orb, /state="listening"/u);
+ assert.match(orb, /state="weaving"/u);
assert.doesNotMatch(orb, /Rive|\.riv/u);
assert.match(styles, /\.aiden-live-orb-canvas[\s\S]*filter:[\s\S]*hue-rotate\(209deg\)/u);
assert.match(
diff --git a/renderer/components/assistant/use-assistant-live.test.tsx b/renderer/components/assistant/use-assistant-live.test.tsx
index b9ae1b6d9..9eb6a74fa 100644
--- a/renderer/components/assistant/use-assistant-live.test.tsx
+++ b/renderer/components/assistant/use-assistant-live.test.tsx
@@ -1,5 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
+import { LiveAudioDeviceError } from "../../lib/live-audio-devices.js";
+
+test("Live device preparation failure prevents provider start and surfaces recovery", async () => {
+ const fixture = await mountHook({ prepareAudioSession: async () => { throw new LiveAudioDeviceError("Choose another output in Aiden Live settings."); } });
+ try {
+ await fixture.controller().start();
+ await settle();
+ assert.equal(fixture.startCalls(), 0);
+ assert.match(fixture.controller().error ?? "", /Choose another output/);
+ } finally { await fixture.unmount(); }
+});
+
+test("cancelling device preparation fences provider start", async () => {
+ const pending = deferred();
+ const fixture = await mountHook({ prepareAudioSession: () => pending.promise });
+ try {
+ const starting = fixture.controller().start();
+ await settle();
+ await fixture.controller().cancelSetup();
+ pending.resolve();
+ await starting;
+ assert.equal(fixture.startCalls(), 0);
+ } finally { await fixture.unmount(); }
+});
+
+test("output preparation closes its context when cancelled during sink selection", async () => {
+ const pending = deferred();
+ let closed = false;
+ const context = { setSinkId: () => pending.promise, close: async () => { closed = true; } } as unknown as AudioContext;
+ const player = new PcmPlayer(() => context);
+ const abort = new AbortController();
+ const preparing = player.prepareOutput("speaker", abort.signal);
+ await settle();
+ abort.abort();
+ pending.resolve();
+ await preparing;
+ assert.equal(closed, true);
+});
import { DOMImplementation } from "@xmldom/xmldom";
import type {
AssistantLiveRendererEvent,
@@ -106,7 +144,7 @@ interface HookFixture {
contexts: FakeCaptureContext[];
tracks: FakeTrack[];
player: RecordingPlayer;
- startIntents(): Array<{ microphone: boolean; computerUseAuthorization: string | null }>;
+ startIntents(): Array<{ microphone: boolean; screen?: boolean; computerUseAuthorization: string | null }>;
refreshAvailability(): Promise;
unmount(): Promise;
}
@@ -287,6 +325,52 @@ async function mountHook(overrides: Partial = {}): Pr
};
}
+test("connection cues wait for microphone readiness, deduplicate resume, and sound on stop", async () => {
+ const cues: string[] = [];
+ const media = deferred();
+ const fixture = await mountHook({
+ getUserMedia: () => media.promise,
+ playConnectionCue: async (cue) => { cues.push(cue); },
+ });
+ try {
+ const starting = fixture.controller().start();
+ await settle();
+ assert.deepEqual(cues, []);
+ media.resolve({ getTracks: () => [new FakeTrack()] } as unknown as MediaStream);
+ await starting;
+ await settle();
+ await settle();
+ assert.deepEqual(cues, ["connected"]);
+ for (const state of ["resuming", "open", "open"] as const) {
+ fixture.emit({ type: "snapshot", snapshot: { available: true, reason: "available", sessionId: "session-1", state } });
+ await settle();
+ }
+ assert.deepEqual(cues, ["connected"]);
+ await fixture.controller().stop();
+ await settle();
+ await settle();
+ assert.deepEqual(cues, ["connected", "disconnected"]);
+ } finally { await fixture.unmount(); }
+});
+
+test("unexpected disconnect sounds once even if cue playback fails", async () => {
+ const cues: string[] = [];
+ const fixture = await mountHook({ playConnectionCue: async (cue) => {
+ cues.push(cue);
+ throw new Error("audio unavailable");
+ } });
+ try {
+ await fixture.controller().start();
+ await settle();
+ fixture.emit({ type: "snapshot", snapshot: { available: true, reason: "available", sessionId: "session-1", state: "disconnected" } });
+ await settle();
+ fixture.emit({ type: "snapshot", snapshot: { available: true, reason: "available", state: "disconnected" } });
+ await settle();
+ assert.deepEqual(cues, ["connected", "disconnected"]);
+ assert.equal(fixture.controller().microphoneActive, false);
+ } finally { await fixture.unmount(); }
+});
+
test("provider refresh rechecks Live availability without remounting or reconnecting", async () => {
let hasGoogleCredential = false;
let statusCalls = 0;
@@ -470,7 +554,7 @@ test("Computer Use restores the one-time setup opt in after readiness is revalid
assert.match(fixture.controller().computerUseDetail, /Accessibility and Screen Recording/u);
await fixture.controller().start();
assert.deepEqual(fixture.startIntents(), [
- { microphone: true, computerUseAuthorization: "test-authorization" },
+ { microphone: true, computerUseAuthorization: "test-authorization", screen: false },
]);
await fixture.controller().stop();
await fixture.controller().setComputerUse(false);
@@ -1362,3 +1446,224 @@ test("playback interruption stops an already active source", async () => {
assert.equal(stops, 1);
await player.close();
});
+
+class FakeDisplayTrack {
+ readyState = "live";
+ stops = 0;
+ readonly listeners = new Set<() => void>();
+
+ constructor(readonly label = "Aiden window") {}
+
+ addEventListener(_type: "ended", listener: () => void): void {
+ this.listeners.add(listener);
+ }
+
+ removeEventListener(_type: "ended", listener: () => void): void {
+ this.listeners.delete(listener);
+ }
+
+ stop(): void {
+ this.stops += 1;
+ this.readyState = "ended";
+ }
+
+ end(): void {
+ this.readyState = "ended";
+ for (const listener of [...this.listeners]) listener();
+ }
+}
+
+interface ScreenFixtureOptions {
+ snapshot: AssistantLiveSnapshot;
+ startCalls: Array<{ screen: boolean }>;
+ framesSent: Array<{ sessionId: string; frame: Uint8Array }>;
+ binds: number[];
+ releases: number[];
+ displayTrack: FakeDisplayTrack;
+ frameSource: {
+ captures: number;
+ stops: number;
+ capture(): Promise;
+ stop(): void;
+ };
+}
+
+function screenDependencies(): {
+ dependencies: Partial;
+ state: ScreenFixtureOptions;
+} {
+ const displayTrack = new FakeDisplayTrack();
+ const state: ScreenFixtureOptions = {
+ snapshot: {
+ available: true,
+ reason: "available",
+ model: "gemini-live-test",
+ screenShareAllowed: true,
+ state: "idle",
+ },
+ startCalls: [],
+ framesSent: [],
+ binds: [],
+ releases: [],
+ displayTrack,
+ frameSource: {
+ captures: 0,
+ stops: 0,
+ capture: async () => {
+ state.frameSource.captures += 1;
+ return new Uint8Array([0xff, 0xd8, 0x2a, 0xff, 0xd9]);
+ },
+ stop: () => {
+ state.frameSource.stops += 1;
+ },
+ },
+ };
+ const api: AssistantLiveDependencies["api"] = {
+ status: async () => state.snapshot,
+ start: async (intent) => {
+ state.startCalls.push({ screen: intent.screen === true });
+ state.snapshot = {
+ ...state.snapshot,
+ sessionId: `screen-${state.startCalls.length}`,
+ state: "open",
+ };
+ return state.snapshot;
+ },
+ stop: async () => {
+ state.snapshot = { ...state.snapshot, sessionId: undefined, state: "idle" };
+ return state.snapshot;
+ },
+ sendAudio: async () => true,
+ bindDisplay: async () => {
+ state.binds.push(1);
+ return true;
+ },
+ releaseDisplay: async () => {
+ state.releases.push(1);
+ return true;
+ },
+ sendFrame: async (sessionId, frame) => {
+ state.framesSent.push({ sessionId, frame });
+ return true;
+ },
+ onEvent: () => () => undefined,
+ };
+ return {
+ state,
+ dependencies: {
+ api,
+ getDisplayMedia: async () => ({
+ getTracks: () => [state.displayTrack],
+ }),
+ createDisplayFrameSource: async () => state.frameSource,
+ },
+ };
+}
+
+test("a chosen screen source shares one bounded frame only while its session runs", async () => {
+ const { dependencies, state } = screenDependencies();
+ const fixture = await mountHook(dependencies);
+ assert.equal(fixture.controller().screenShareAvailable, true);
+ assert.equal(fixture.controller().screenSourceLabel, null);
+
+ await fixture.controller().chooseScreenSource();
+ await settle();
+ assert.equal(state.binds.length, 1);
+ assert.equal(fixture.controller().screenSourceLabel, "Aiden window");
+ assert.equal(state.startCalls.length, 0, "the picker alone never starts Live");
+ assert.equal(state.framesSent.length, 0, "no frame flows before a session exists");
+
+ await fixture.controller().start();
+ await settle();
+ assert.deepEqual(state.startCalls, [{ screen: true }]);
+ assert.equal(fixture.controller().screenActive, true);
+ assert.equal(state.framesSent.length, 1);
+ assert.equal(state.framesSent[0]?.sessionId, "screen-1");
+ assert.equal(state.frameSource.stops, 0);
+ assert.equal(state.displayTrack.stops, 0);
+
+ await fixture.controller().stop();
+ await settle();
+ const sentAtStop = state.framesSent.length;
+ assert.equal(state.displayTrack.stops, 1);
+ assert.equal(state.frameSource.stops, 1);
+ assert.equal(fixture.controller().screenActive, false);
+ assert.equal(fixture.controller().screenSourceLabel, null);
+ await settle();
+ assert.equal(state.framesSent.length, sentAtStop, "no frame flows after Stop");
+ await fixture.unmount();
+});
+
+test("the picker is unavailable until the screen gate admits this document", async () => {
+ const { dependencies, state } = screenDependencies();
+ state.snapshot = { ...state.snapshot, screenShareAllowed: false };
+ const fixture = await mountHook(dependencies);
+ assert.equal(fixture.controller().screenShareAvailable, false);
+ await fixture.controller().chooseScreenSource();
+ await settle();
+ assert.equal(state.binds.length, 0);
+ assert.equal(fixture.controller().screenSourceLabel, null);
+ await fixture.unmount();
+});
+
+test("a cancelled picker is nonfatal and releases unused binding authority", async () => {
+ const { dependencies, state } = screenDependencies();
+ const fixture = await mountHook({
+ ...dependencies,
+ getDisplayMedia: async () => {
+ throw new DOMException("cancelled", "NotAllowedError");
+ },
+ });
+ await fixture.controller().chooseScreenSource();
+ await settle();
+ assert.equal(state.binds.length, 1);
+ assert.equal(state.releases.length, 1, "a failed first pick releases authority");
+ assert.equal(fixture.controller().screenError, null);
+ assert.equal(fixture.controller().screenSourceLabel, null);
+ assert.equal(fixture.controller().screenBusy, false);
+ await fixture.unmount();
+});
+
+test("a picker failure that is not cancellation surfaces an accessible error", async () => {
+ const { dependencies, state } = screenDependencies();
+ const fixture = await mountHook({
+ ...dependencies,
+ getDisplayMedia: async () => {
+ throw new Error("display denied");
+ },
+ });
+ await fixture.controller().chooseScreenSource();
+ await settle();
+ assert.equal(state.releases.length, 1);
+ assert.match(fixture.controller().screenError ?? "", /screen picker/iu);
+ await fixture.unmount();
+});
+
+test("an externally ended display source stops the Live session instead of streaming a dead feed", async () => {
+ const { dependencies, state } = screenDependencies();
+ const fixture = await mountHook(dependencies);
+ await fixture.controller().chooseScreenSource();
+ await fixture.controller().start();
+ await settle();
+ assert.equal(fixture.controller().screenActive, true);
+
+ state.displayTrack.end();
+ await settle();
+ await settle();
+ assert.equal(fixture.controller().active, false, "the session stops with its source");
+ assert.equal(fixture.controller().screenActive, false);
+ assert.equal(fixture.controller().screenSourceLabel, null);
+ await fixture.unmount();
+});
+
+test("Live without a chosen source starts audio-only and the picker stays re-armable", async () => {
+ const { dependencies, state } = screenDependencies();
+ const fixture = await mountHook(dependencies);
+ await fixture.controller().start();
+ await settle();
+ assert.deepEqual(state.startCalls, [{ screen: false }]);
+ assert.equal(fixture.controller().screenActive, false);
+ assert.equal(state.frameSource.captures, 0);
+ await fixture.controller().stop();
+ await fixture.unmount();
+});
diff --git a/renderer/components/assistant/use-assistant-live.ts b/renderer/components/assistant/use-assistant-live.ts
index e0abeaf7a..5b22e046a 100644
--- a/renderer/components/assistant/use-assistant-live.ts
+++ b/renderer/components/assistant/use-assistant-live.ts
@@ -1,13 +1,22 @@
import * as React from "react";
+import { captureLiveMicrophone, LiveAudioDeviceError, readLiveAudioDevices, routeLiveAudioOutput } from "../../lib/live-audio-devices";
+import { playLiveConnectionCue } from "../../lib/dictation-sounds";
import { assistantLiveApi, computerUseApi } from "../../lib/ipc";
import { useAppCapabilities } from "../../lib/app-capabilities";
import { useProviders } from "../../lib/queries";
import type { ComputerUseStatus } from "../../lib/types";
import {
+ bindDisplayCaptureLifecycle,
+ GEMINI_LIVE_FRAME_INTERVAL_MS,
+ GEMINI_LIVE_FRAME_JPEG_QUALITY,
+ GEMINI_LIVE_MAX_FRAME_BYTES,
GEMINI_LIVE_PCM_WORKLET_NAME,
+ geminiLiveScaledFrameSize,
GeminiLivePcmPlaybackQueue,
loadGeminiLivePcmWorklet,
measureGeminiLivePcmLevel,
+ type DisplayMediaStream,
+ type GeminiLiveDisplayFrameSource,
} from "../../lib/gemini-live-media-core";
import type {
AssistantLiveRendererEvent,
@@ -57,12 +66,19 @@ export interface AssistantLiveController {
computerUseDetail: string;
computerUsePermissions: ComputerUseStatus["permissions"] | null;
computerUseError: string | null;
+ screenShareAvailable: boolean;
+ screenSourceLabel: string | null;
+ screenActive: boolean;
+ screenBusy: boolean;
+ screenError: string | null;
setupComplete: boolean;
setSetupOpen(open: boolean): void;
setMicrophone(enabled: boolean): void;
setComputerUse(enabled: boolean): Promise;
requestMicrophonePermission(): Promise;
prepareComputerUse(): Promise;
+ chooseScreenSource(): Promise;
+ releaseScreen(): void;
start(): Promise;
stop(): Promise;
cancelSetup(): Promise;
@@ -119,6 +135,7 @@ export function assistantLiveMicrophonePermissionDetail(
}
export function assistantLiveStartErrorDetail(error: unknown): string {
+ if (error instanceof LiveAudioDeviceError) return error.message;
const message = error instanceof Error ? error.message : "Live could not start.";
if (/Google rejected this API key for Live/u.test(message)) return message;
if (/Google Live quota is unavailable/u.test(message)) return message;
@@ -173,10 +190,28 @@ export class PcmPlayer {
constructor(private readonly createContext = () => new AudioContext({ sampleRate: 24_000 })) {}
+ async prepareOutput(deviceId: string, signal: AbortSignal): Promise {
+ await this.close();
+ if (signal.aborted) return;
+ const context = this.createContext();
+ try {
+ await routeLiveAudioOutput(context, deviceId);
+ if (signal.aborted) {
+ await context.close();
+ return;
+ }
+ this.context = context;
+ } catch (error) {
+ await context.close().catch(() => undefined);
+ throw error;
+ }
+ }
+
enqueue(pcm: Uint8Array): void {
if (this.paused) return;
this.queue.enqueue(pcm);
void this.playNext().catch(() => {
+ console.info("[aiden-live] playback-failed");
this.starting = false;
});
}
@@ -260,6 +295,7 @@ export class PcmPlayer {
this.source = source;
this.starting = false;
source.start();
+ if (context.currentTime < 1) console.info("[aiden-live] playback-started");
}
}
@@ -268,10 +304,14 @@ interface AssistantLiveApi {
authorizeComputerUse?(): Promise;
start(intent: {
microphone: boolean;
+ screen?: boolean;
computerUseAuthorization: string | null;
}): Promise;
stop(): Promise;
sendAudio(sessionId: string, pcm: Uint8Array): Promise;
+ bindDisplay?(): Promise;
+ releaseDisplay?(): Promise;
+ sendFrame?(sessionId: string, frame: Uint8Array): Promise;
onEvent(handler: (event: AssistantLiveRendererEvent) => void): () => void;
}
@@ -289,6 +329,12 @@ export interface AssistantLiveDependencies {
createWorklet(context: AudioContext): AudioWorkletNode;
loadWorklet(context: AudioContext): Promise;
createPlayer(): PcmPlayer;
+ prepareAudioSession?(signal: AbortSignal): Promise;
+ playConnectionCue?(kind: "connected" | "disconnected"): Promise;
+ getDisplayMedia?(): Promise;
+ createDisplayFrameSource?(
+ stream: DisplayMediaStream,
+ ): Promise;
computerUse: {
status(): Promise;
setEnabled?(enabled: boolean): Promise;
@@ -298,24 +344,58 @@ export interface AssistantLiveDependencies {
}
function defaultDependencies(geminiLive: boolean): AssistantLiveDependencies {
+ let devices = { input: "default", output: "default" };
+ const player = new PcmPlayer();
return {
geminiLive,
api: assistantLiveApi,
askForMicrophone: () => window.aidenAPI.systemPreferences.askForMediaAccess("microphone"),
getMicrophoneStatus: () => window.aidenAPI.systemPreferences.getMediaAccessStatus("microphone"),
- getUserMedia: () =>
- navigator.mediaDevices.getUserMedia({
- audio: {
- channelCount: 1,
- echoCancellation: true,
- noiseSuppression: true,
- },
- video: false,
- }),
+ getUserMedia: () => captureLiveMicrophone(devices.input),
createCaptureContext: () => new AudioContext(),
createWorklet: (context) => new AudioWorkletNode(context, GEMINI_LIVE_PCM_WORKLET_NAME),
loadWorklet: (context) => loadGeminiLivePcmWorklet(context, window.location.href),
- createPlayer: () => new PcmPlayer(),
+ createPlayer: () => player,
+ prepareAudioSession: async (signal) => {
+ devices = readLiveAudioDevices();
+ await player.prepareOutput(devices.output, signal);
+ },
+ playConnectionCue: async (kind) => {
+ try {
+ await playLiveConnectionCue(kind, devices.output);
+ console.info(`[aiden-live] cue-${kind}`);
+ } catch {
+ console.info("[aiden-live] cue-failed");
+ }
+ },
+ getDisplayMedia: () =>
+ navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }),
+ createDisplayFrameSource: async (stream) => {
+ const video = document.createElement("video");
+ video.muted = true;
+ video.srcObject = stream as MediaStream;
+ await video.play();
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+ if (!context) throw new Error("Live screen capture is unavailable.");
+ return {
+ capture: async () => {
+ if (!video.videoWidth || !video.videoHeight) return null;
+ const size = geminiLiveScaledFrameSize(video.videoWidth, video.videoHeight);
+ if (canvas.width !== size.width) canvas.width = size.width;
+ if (canvas.height !== size.height) canvas.height = size.height;
+ context.drawImage(video, 0, 0, size.width, size.height);
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob(resolve, "image/jpeg", GEMINI_LIVE_FRAME_JPEG_QUALITY),
+ );
+ if (!blob || blob.size < 4 || blob.size > GEMINI_LIVE_MAX_FRAME_BYTES) return null;
+ return new Uint8Array(await blob.arrayBuffer());
+ },
+ stop: () => {
+ video.srcObject = null;
+ },
+ };
+ },
computerUse: computerUseApi,
};
}
@@ -412,6 +492,14 @@ export function useAssistantLiveWithDependencies(
sessionId: string;
cleanup: () => Promise;
} | null>(null);
+ const screenStreamRef = React.useRef(null);
+ const screenFinishRef = React.useRef<(() => void) | null>(null);
+ const screenPumpRef = React.useRef<{
+ sessionId: string;
+ timer: ReturnType;
+ source: GeminiLiveDisplayFrameSource;
+ } | null>(null);
+ const stopSessionRef = React.useRef<(() => Promise) | null>(null);
const playerRef = React.useRef(null);
if (!playerRef.current) playerRef.current = dependencies.createPlayer();
const captionId = React.useRef(0);
@@ -448,6 +536,49 @@ export function useAssistantLiveWithDependencies(
const [computerUseActing, setComputerUseActing] = React.useState(false);
const [computerUseBusy, setComputerUseBusy] = React.useState(false);
const [computerUseError, setComputerUseError] = React.useState(null);
+ const audibleSession = React.useRef(null);
+ const receivedAudioSession = React.useRef(null);
+ React.useEffect(() => {
+ let cue: "connected" | "disconnected" | null = null;
+ if (snapshot.state === "open" && microphoneActive && snapshot.sessionId) {
+ if (audibleSession.current !== snapshot.sessionId) {
+ audibleSession.current = snapshot.sessionId;
+ cue = "connected";
+ }
+ } else if (
+ audibleSession.current &&
+ ["idle", "closed", "failed", "disconnected"].includes(snapshot.state)
+ ) {
+ audibleSession.current = null;
+ cue = "disconnected";
+ }
+ // Audio feedback must never block capture, teardown, or reconnect recovery.
+ if (cue) void dependencies.playConnectionCue?.(cue).catch(() => undefined);
+ }, [snapshot.state, snapshot.sessionId, microphoneActive, dependencies.playConnectionCue]);
+ const [screenSourceLabel, setScreenSourceLabel] = React.useState(null);
+ const [screenActive, setScreenActive] = React.useState(false);
+ const [screenBusy, setScreenBusy] = React.useState(false);
+ const [screenError, setScreenError] = React.useState(null);
+
+ const active = Boolean(sessionRef.current) && activeSnapshot(snapshot);
+
+ /** Idempotent: clears the frame pump, then ends every display track once. */
+ const stopScreenCapture = React.useCallback(() => {
+ const pump = screenPumpRef.current;
+ screenPumpRef.current = null;
+ if (pump) {
+ clearInterval(pump.timer);
+ pump.source.stop();
+ }
+ const finish = screenFinishRef.current;
+ screenFinishRef.current = null;
+ finish?.();
+ screenStreamRef.current = null;
+ if (mounted.current) {
+ setScreenActive(false);
+ setScreenSourceLabel(null);
+ }
+ }, []);
const teardownMedia = React.useCallback(
async (expected?: { sessionId: string; generation: number }) => {
@@ -460,6 +591,7 @@ export function useAssistantLiveWithDependencies(
) {
return;
}
+ stopScreenCapture();
const teardownGeneration = ++mediaGeneration.current;
mediaCleanupRef.current = null;
microphoneLevelRef.current = 0;
@@ -473,7 +605,7 @@ export function useAssistantLiveWithDependencies(
await playerRef.current?.close();
}
},
- [],
+ [stopScreenCapture],
);
const acceptEvent = React.useCallback(
@@ -499,6 +631,10 @@ export function useAssistantLiveWithDependencies(
}
if (event.sessionId !== sessionRef.current) return;
if (event.type === "audio" && event.pcm instanceof Uint8Array) {
+ if (receivedAudioSession.current !== event.sessionId) {
+ receivedAudioSession.current = event.sessionId;
+ console.info("[aiden-live] output-first-packet");
+ }
playerRef.current?.enqueue(event.pcm);
} else if (event.type === "playback_flush") {
playerRef.current?.flush();
@@ -568,6 +704,7 @@ export function useAssistantLiveWithDependencies(
sessionRef.current = null;
void teardownMedia();
void dependencies.api.stop().catch(() => undefined);
+ void dependencies.api.releaseDisplay?.().catch(() => undefined);
};
}, [acceptEvent, dependencies.api, dependencies.geminiLive, teardownMedia]);
@@ -702,6 +839,119 @@ export function useAssistantLiveWithDependencies(
dependencies.computerUse,
]);
+ const releaseScreen = React.useCallback(() => {
+ stopScreenCapture();
+ void dependencies.api.releaseDisplay?.().catch(() => undefined);
+ }, [dependencies.api, stopScreenCapture]);
+
+ const chooseScreenSource = React.useCallback(async () => {
+ if (
+ screenBusy ||
+ active ||
+ snapshot.screenShareAllowed !== true ||
+ !dependencies.getDisplayMedia ||
+ !dependencies.api.bindDisplay
+ )
+ return;
+ setScreenBusy(true);
+ setScreenError(null);
+ try {
+ if (!(await dependencies.api.bindDisplay())) {
+ throw new Error("Screen sharing is unavailable for this window.");
+ }
+ const stream = await dependencies.getDisplayMedia();
+ if (!mounted.current || sessionRef.current !== null) {
+ for (const track of stream.getTracks()) track.stop();
+ return;
+ }
+ stopScreenCapture();
+ screenStreamRef.current = stream;
+ const finish = bindDisplayCaptureLifecycle(stream, {
+ onStopped: (reason) => {
+ stopScreenCapture();
+ if (reason === "ended") void stopSessionRef.current?.();
+ },
+ });
+ // The lifecycle can resolve synchronously for an already-ended track;
+ // never advertise a source whose capture already finished.
+ if (screenStreamRef.current !== stream) return;
+ screenFinishRef.current = () => finish("stopped");
+ setScreenSourceLabel(stream.getTracks()[0]?.label?.trim() || "screen");
+ } catch (cause) {
+ // A cancelled picker keeps any previously chosen source; only a failed
+ // first pick releases this document's binding authority.
+ if (!screenStreamRef.current) {
+ void dependencies.api.releaseDisplay?.().catch(() => undefined);
+ }
+ if (mounted.current) {
+ const name = cause instanceof DOMException ? cause.name : "";
+ setScreenError(
+ name === "AbortError" || name === "NotAllowedError"
+ ? null
+ : "Aiden could not open the screen picker. Try again.",
+ );
+ }
+ } finally {
+ if (mounted.current) setScreenBusy(false);
+ }
+ }, [
+ active,
+ dependencies.api,
+ dependencies.getDisplayMedia,
+ screenBusy,
+ snapshot.screenShareAllowed,
+ stopScreenCapture,
+ ]);
+
+ const startScreenShare = React.useCallback(
+ async (sessionId: string, signal: AbortSignal) => {
+ const stream = screenStreamRef.current;
+ if (!stream) return;
+ const isCurrent = () =>
+ mounted.current && !signal.aborted && sessionRef.current === sessionId;
+ if (!dependencies.createDisplayFrameSource || !dependencies.api.sendFrame) {
+ stopScreenCapture();
+ void dependencies.api.releaseDisplay?.().catch(() => undefined);
+ return;
+ }
+ try {
+ const source = await dependencies.createDisplayFrameSource(stream);
+ if (!isCurrent()) {
+ source.stop();
+ return;
+ }
+ let inFlight = false;
+ const sendOnce = async () => {
+ if (inFlight || !isCurrent()) return;
+ inFlight = true;
+ try {
+ const frame = await source.capture();
+ if (frame && isCurrent()) {
+ await dependencies.api
+ .sendFrame?.(sessionId, frame)
+ .catch(() => false);
+ }
+ } catch {
+ // A skipped frame is never fatal; the next interval sends fresh.
+ } finally {
+ inFlight = false;
+ }
+ };
+ const timer = setInterval(
+ () => void sendOnce(),
+ GEMINI_LIVE_FRAME_INTERVAL_MS,
+ );
+ screenPumpRef.current = { sessionId, timer, source };
+ setScreenActive(true);
+ void sendOnce();
+ } catch {
+ stopScreenCapture();
+ void dependencies.api.releaseDisplay?.().catch(() => undefined);
+ }
+ },
+ [dependencies, stopScreenCapture],
+ );
+
const startMicrophone = React.useCallback(
async (sessionId: string, signal: AbortSignal) => {
const generation = ++mediaGeneration.current;
@@ -736,6 +986,7 @@ export function useAssistantLiveWithDependencies(
silent.gain.value = 0;
source.connect(worklet).connect(silent).connect(context.destination);
let stopped = false;
+ let loggedInput = false;
let audioSendFailed = false;
let inFlight = 0;
const stopAfterAudioFailure = async () => {
@@ -792,6 +1043,10 @@ export function useAssistantLiveWithDependencies(
const data = message.data as { type?: unknown; data?: unknown };
if (data?.type !== "pcm" || !(data.data instanceof ArrayBuffer)) return;
const pcm = new Uint8Array(data.data);
+ if (!loggedInput) {
+ loggedInput = true;
+ console.info("[aiden-live] input-first-packet");
+ }
const measured = measureGeminiLivePcmLevel(pcm);
const previous = microphoneLevelRef.current;
const smoothed =
@@ -836,6 +1091,7 @@ export function useAssistantLiveWithDependencies(
await context.resume();
assertCurrent();
setMicrophoneActive(true);
+ console.info("[aiden-live] microphone-ready");
} catch (startError) {
for (const track of stream.getTracks()) track.stop();
await context.close().catch(() => undefined);
@@ -867,6 +1123,8 @@ export function useAssistantLiveWithDependencies(
setVoiceApprovalReceipts([]);
setComputerUseActing(false);
try {
+ await dependencies.prepareAudioSession?.(setupAbort.signal);
+ if (setupAbort.signal.aborted || !mounted.current || operationGeneration.current !== generation) return;
const computerUseAuthorization = computerUseEnabled
? (await dependencies.api.authorizeComputerUse?.()) ?? null
: null;
@@ -887,6 +1145,7 @@ export function useAssistantLiveWithDependencies(
const next = await dependencies.api.start({
microphone: true,
computerUseAuthorization,
+ screen: Boolean(screenStreamRef.current),
});
if (
!mounted.current ||
@@ -901,6 +1160,7 @@ export function useAssistantLiveWithDependencies(
if (next.state === "resuming") playerRef.current?.pauseAndFlush();
else if (next.state === "open") playerRef.current?.resume();
await startMicrophone(next.sessionId, setupAbort.signal);
+ await startScreenShare(next.sessionId, setupAbort.signal);
if (
mounted.current &&
!setupAbort.signal.aborted &&
@@ -932,6 +1192,7 @@ export function useAssistantLiveWithDependencies(
microphonePermission,
snapshot.available,
startMicrophone,
+ startScreenShare,
teardownMedia,
]);
@@ -947,6 +1208,7 @@ export function useAssistantLiveWithDependencies(
await teardownMedia();
try {
await dependencies.api.stop();
+ void dependencies.api.releaseDisplay?.().catch(() => undefined);
const next = await dependencies.api.status();
if (mounted.current && operationGeneration.current === generation) {
sessionRef.current = activeSnapshot(next) ? (next.sessionId ?? null) : null;
@@ -1000,6 +1262,13 @@ export function useAssistantLiveWithDependencies(
await stop();
}, [stop]);
+ React.useEffect(() => {
+ stopSessionRef.current = stop;
+ return () => {
+ stopSessionRef.current = null;
+ };
+ }, [stop]);
+
const setSetupOpen = React.useCallback(
(open: boolean) => {
if (!open && busy) {
@@ -1007,11 +1276,12 @@ export function useAssistantLiveWithDependencies(
return;
}
setSetupOpenState(open);
+ // A dismissed setup must never keep an unused capture alive.
+ if (!open && !sessionRef.current) releaseScreen();
},
- [busy, cancelSetup],
+ [busy, cancelSetup, releaseScreen],
);
- const active = Boolean(sessionRef.current) && activeSnapshot(snapshot);
const availabilityDetail = assistantLiveAvailabilityDetail(snapshot);
const microphonePermissionReady = ["granted", "not-determined"].includes(microphonePermission);
const microphonePermissionDetail = assistantLiveMicrophonePermissionDetail(microphonePermission);
@@ -1055,12 +1325,19 @@ export function useAssistantLiveWithDependencies(
computerUseDetail: computerUseStatus?.detail ?? "Checking global Computer Use readiness…",
computerUsePermissions: computerUseStatus?.permissions ?? null,
computerUseError,
+ screenShareAvailable: snapshot.screenShareAllowed === true,
+ screenSourceLabel,
+ screenActive,
+ screenBusy,
+ screenError,
setupComplete,
setSetupOpen,
setMicrophone,
setComputerUse,
requestMicrophonePermission,
prepareComputerUse,
+ chooseScreenSource,
+ releaseScreen,
start,
stop,
cancelSetup,
@@ -1072,14 +1349,17 @@ export function useAssistantLive(
): AssistantLiveController {
const { geminiLive } = useAppCapabilities();
const providers = useProviders();
+ // Audio ownership survives capability refresh; playerRef must share this same player.
+ const [audioDependencies] = React.useState(() => defaultDependencies(false));
const dependencies = React.useMemo(
() => ({
- ...defaultDependencies(geminiLive),
+ ...audioDependencies,
+ geminiLive,
availabilityRefreshReady: providers.fetchStatus === "idle",
availabilityRefreshToken: providers.dataUpdatedAt,
ordinaryBusyReason,
}),
- [geminiLive, ordinaryBusyReason, providers.dataUpdatedAt, providers.fetchStatus],
+ [audioDependencies, geminiLive, ordinaryBusyReason, providers.dataUpdatedAt, providers.fetchStatus],
);
return useAssistantLiveWithDependencies(dependencies);
}
diff --git a/renderer/components/settings/gemini-live-settings.tsx b/renderer/components/settings/gemini-live-settings.tsx
index 57420723e..2ddb95a98 100644
--- a/renderer/components/settings/gemini-live-settings.tsx
+++ b/renderer/components/settings/gemini-live-settings.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { LiveAudioSettings } from "./live-audio-settings";
import { useNavigate } from "@tanstack/react-router";
import {
AudioWaveform,
@@ -73,8 +74,8 @@ export function AidenLiveSettings(): React.ReactElement {
Beta
- Speak naturally, let Aiden read the current screen, and approve each click or typed
- action one at a time. Availability and supported actions may change during beta.
+ Speak naturally and let Aiden act on your screen during Live. Stop Live to end
+ session access. Availability and supported actions may change during beta.
@@ -120,6 +121,8 @@ export function AidenLiveSettings(): React.ReactElement {
+
+