@openclaw/google-meet 2026.5.3-beta.2 → 2026.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -143,6 +143,12 @@ function writeDoctorStatus(status) {
143
143
  writeStdoutLine("bridge closed: %s", formatBoolean(health?.bridgeClosed));
144
144
  writeStdoutLine("browser url: %s", formatOptional(health?.browserUrl));
145
145
  if (health?.lastCaptionText) writeStdoutLine("last caption text: %s%s", health.lastCaptionSpeaker ? `${health.lastCaptionSpeaker}: ` : "", health.lastCaptionText);
146
+ writeStdoutLine("realtime transcript lines: %s", health?.realtimeTranscriptLines ?? 0);
147
+ if (health?.lastRealtimeTranscriptText) writeStdoutLine("last realtime transcript: %s%s", health.lastRealtimeTranscriptRole ? `${health.lastRealtimeTranscriptRole}: ` : "", health.lastRealtimeTranscriptText);
148
+ if (health?.lastRealtimeEventType) {
149
+ const detail = health.lastRealtimeEventDetail ? ` ${health.lastRealtimeEventDetail}` : "";
150
+ writeStdoutLine("last realtime event: %s%s", health.lastRealtimeEventType, detail);
151
+ }
146
152
  }
147
153
  }
148
154
  function sanitizeOAuthErrorMessage(error) {
package/dist/index.js CHANGED
@@ -342,6 +342,42 @@ async function consultOpenClawAgentForGoogleMeet(params) {
342
342
  }
343
343
  //#endregion
344
344
  //#region extensions/google-meet/src/realtime.ts
345
+ function recordGoogleMeetRealtimeTranscript(transcript, role, text) {
346
+ const entry = {
347
+ at: (/* @__PURE__ */ new Date()).toISOString(),
348
+ role,
349
+ text
350
+ };
351
+ transcript.push(entry);
352
+ if (transcript.length > 40) transcript.splice(0, transcript.length - 40);
353
+ return entry;
354
+ }
355
+ function getGoogleMeetRealtimeTranscriptHealth(transcript) {
356
+ const last = transcript.at(-1);
357
+ return {
358
+ realtimeTranscriptLines: transcript.length,
359
+ lastRealtimeTranscriptAt: last?.at,
360
+ lastRealtimeTranscriptRole: last?.role,
361
+ lastRealtimeTranscriptText: last?.text,
362
+ recentRealtimeTranscript: transcript.slice(-5)
363
+ };
364
+ }
365
+ function recordGoogleMeetRealtimeEvent(events, event) {
366
+ events.push({
367
+ at: (/* @__PURE__ */ new Date()).toISOString(),
368
+ ...event
369
+ });
370
+ if (events.length > 40) events.splice(0, events.length - 40);
371
+ }
372
+ function getGoogleMeetRealtimeEventHealth(events) {
373
+ const last = events.at(-1);
374
+ return {
375
+ lastRealtimeEventAt: last?.at,
376
+ lastRealtimeEventType: last ? `${last.direction}:${last.type}` : void 0,
377
+ lastRealtimeEventDetail: last?.detail,
378
+ recentRealtimeEvents: events.slice(-10)
379
+ };
380
+ }
345
381
  function splitCommand$1(argv) {
346
382
  const [command, ...args] = argv;
347
383
  if (!command) throw new Error("audio bridge command must not be empty");
@@ -528,6 +564,7 @@ async function startCommandRealtimeAudioBridge(params) {
528
564
  providers: params.providers
529
565
  });
530
566
  const transcript = [];
567
+ const realtimeEvents = [];
531
568
  bridge = createRealtimeVoiceBridgeSession({
532
569
  provider: resolved.provider,
533
570
  providerConfig: resolved.providerConfig,
@@ -550,12 +587,15 @@ async function startCommandRealtimeAudioBridge(params) {
550
587
  },
551
588
  onTranscript: (role, text, isFinal) => {
552
589
  if (isFinal) {
553
- transcript.push({
554
- role,
555
- text
556
- });
557
- if (transcript.length > 40) transcript.splice(0, transcript.length - 40);
558
- params.logger.debug?.(`[google-meet] ${role}: ${text}`);
590
+ recordGoogleMeetRealtimeTranscript(transcript, role, text);
591
+ params.logger.info(`[google-meet] realtime ${role}: ${text}`);
592
+ }
593
+ },
594
+ onEvent: (event) => {
595
+ recordGoogleMeetRealtimeEvent(realtimeEvents, event);
596
+ if (event.type === "error" || event.type === "response.done") {
597
+ const detail = event.detail ? ` ${event.detail}` : "";
598
+ params.logger.info(`[google-meet] realtime ${event.direction}:${event.type}${detail}`);
559
599
  }
560
600
  },
561
601
  onToolCall: (event, session) => {
@@ -620,6 +660,8 @@ async function startCommandRealtimeAudioBridge(params) {
620
660
  lastInputBytes,
621
661
  lastOutputBytes,
622
662
  suppressedInputBytes,
663
+ ...getGoogleMeetRealtimeTranscriptHealth(transcript),
664
+ ...getGoogleMeetRealtimeEventHealth(realtimeEvents),
623
665
  lastClearAt,
624
666
  clearCount,
625
667
  bridgeClosed: stopped
@@ -653,6 +695,7 @@ async function startNodeRealtimeAudioBridge(params) {
653
695
  providers: params.providers
654
696
  });
655
697
  const transcript = [];
698
+ const realtimeEvents = [];
656
699
  const stop = async () => {
657
700
  if (stopped) return;
658
701
  stopped = true;
@@ -722,12 +765,15 @@ async function startNodeRealtimeAudioBridge(params) {
722
765
  },
723
766
  onTranscript: (role, text, isFinal) => {
724
767
  if (isFinal) {
725
- transcript.push({
726
- role,
727
- text
728
- });
729
- if (transcript.length > 40) transcript.splice(0, transcript.length - 40);
730
- params.logger.debug?.(`[google-meet] ${role}: ${text}`);
768
+ recordGoogleMeetRealtimeTranscript(transcript, role, text);
769
+ params.logger.info(`[google-meet] node realtime ${role}: ${text}`);
770
+ }
771
+ },
772
+ onEvent: (event) => {
773
+ recordGoogleMeetRealtimeEvent(realtimeEvents, event);
774
+ if (event.type === "error" || event.type === "response.done") {
775
+ const detail = event.detail ? ` ${event.detail}` : "";
776
+ params.logger.info(`[google-meet] node realtime ${event.direction}:${event.type}${detail}`);
731
777
  }
732
778
  },
733
779
  onToolCall: (event, session) => {
@@ -818,6 +864,8 @@ async function startNodeRealtimeAudioBridge(params) {
818
864
  lastClearAt,
819
865
  lastInputBytes,
820
866
  lastOutputBytes,
867
+ ...getGoogleMeetRealtimeTranscriptHealth(transcript),
868
+ ...getGoogleMeetRealtimeEventHealth(realtimeEvents),
821
869
  consecutiveInputErrors,
822
870
  lastInputError,
823
871
  clearCount,
@@ -850,8 +898,8 @@ async function assertBlackHole2chAvailable(params) {
850
898
  }
851
899
  }
852
900
  async function launchChromeMeet(params) {
853
- let audioBridge;
854
- if (params.mode === "realtime") {
901
+ const checkRealtimeAudioPrerequisites = async () => {
902
+ if (params.mode !== "realtime") return;
855
903
  await assertBlackHole2chAvailable({
856
904
  runtime: params.runtime,
857
905
  timeoutMs: Math.min(params.config.chrome.joinTimeoutMs, 1e4)
@@ -860,50 +908,44 @@ async function launchChromeMeet(params) {
860
908
  const health = await params.runtime.system.runCommandWithTimeout(params.config.chrome.audioBridgeHealthCommand, { timeoutMs: params.config.chrome.joinTimeoutMs });
861
909
  if (health.code !== 0) throw new Error(`Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`);
862
910
  }
911
+ };
912
+ const startRealtimeAudioBridge = async () => {
913
+ if (params.mode !== "realtime") return;
863
914
  if (params.config.chrome.audioBridgeCommand) {
864
915
  const bridge = await params.runtime.system.runCommandWithTimeout(params.config.chrome.audioBridgeCommand, { timeoutMs: params.config.chrome.joinTimeoutMs });
865
916
  if (bridge.code !== 0) throw new Error(`failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`);
866
- audioBridge = { type: "external-command" };
867
- } else {
868
- if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) throw new Error("Chrome realtime mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.");
869
- audioBridge = {
870
- type: "command-pair",
871
- ...await startCommandRealtimeAudioBridge({
872
- config: params.config,
873
- fullConfig: params.fullConfig,
874
- runtime: params.runtime,
875
- meetingSessionId: params.meetingSessionId,
876
- inputCommand: params.config.chrome.audioInputCommand,
877
- outputCommand: params.config.chrome.audioOutputCommand,
878
- logger: params.logger
879
- })
880
- };
917
+ return { type: "external-command" };
881
918
  }
882
- }
919
+ if (!params.config.chrome.audioInputCommand || !params.config.chrome.audioOutputCommand) throw new Error("Chrome realtime mode requires chrome.audioInputCommand and chrome.audioOutputCommand, or chrome.audioBridgeCommand for an external bridge.");
920
+ return {
921
+ type: "command-pair",
922
+ ...await startCommandRealtimeAudioBridge({
923
+ config: params.config,
924
+ fullConfig: params.fullConfig,
925
+ runtime: params.runtime,
926
+ meetingSessionId: params.meetingSessionId,
927
+ inputCommand: params.config.chrome.audioInputCommand,
928
+ outputCommand: params.config.chrome.audioOutputCommand,
929
+ logger: params.logger
930
+ })
931
+ };
932
+ };
933
+ await checkRealtimeAudioPrerequisites();
883
934
  if (!params.config.chrome.launch) return {
884
935
  launched: false,
885
- audioBridge
936
+ audioBridge: await startRealtimeAudioBridge()
886
937
  };
887
- let commandPairBridgeStopped = false;
888
- const stopCommandPairBridge = async () => {
889
- if (commandPairBridgeStopped) return;
890
- commandPairBridgeStopped = true;
891
- if (audioBridge?.type === "command-pair") await audioBridge.stop();
938
+ const result = await openMeetWithBrowserRequest({
939
+ callBrowser: callLocalBrowserRequest,
940
+ config: params.config,
941
+ mode: params.mode,
942
+ url: params.url
943
+ });
944
+ const audioBridge = params.mode === "realtime" && result.browser?.inCall === true && result.browser.micMuted !== true && result.browser.manualActionRequired !== true ? await startRealtimeAudioBridge() : void 0;
945
+ return {
946
+ ...result,
947
+ audioBridge
892
948
  };
893
- try {
894
- return {
895
- ...await openMeetWithBrowserRequest({
896
- callBrowser: callLocalBrowserRequest,
897
- config: params.config,
898
- mode: params.mode,
899
- url: params.url
900
- }),
901
- audioBridge
902
- };
903
- } catch (error) {
904
- await stopCommandPairBridge();
905
- throw error;
906
- }
907
949
  }
908
950
  function parseNodeStartResult(raw) {
909
951
  const value = raw && typeof raw === "object" && "payload" in raw ? raw.payload : raw;
@@ -970,6 +1012,7 @@ async function grantMeetMediaPermissions(params) {
970
1012
  origin: "https://meet.google.com",
971
1013
  permissions: ["audioCapture", "videoCapture"],
972
1014
  optionalPermissions: ["speakerSelection"],
1015
+ targetId: params.targetId,
973
1016
  timeoutMs: Math.min(params.timeoutMs, 5e3)
974
1017
  },
975
1018
  timeoutMs: Math.min(params.timeoutMs, 5e3)
@@ -983,47 +1026,67 @@ function meetStatusScript(params) {
983
1026
  const text = (node) => (node?.innerText || node?.textContent || "").trim();
984
1027
  const allowMicrophone = ${JSON.stringify(params.allowMicrophone)};
985
1028
  const captureCaptions = ${JSON.stringify(params.captureCaptions)};
1029
+ const readOnly = ${JSON.stringify(Boolean(params.readOnly))};
986
1030
  const buttons = [...document.querySelectorAll('button')];
1031
+ const buttonLabel = (button) =>
1032
+ [
1033
+ button.getAttribute("aria-label"),
1034
+ button.getAttribute("data-tooltip"),
1035
+ text(button),
1036
+ ]
1037
+ .filter(Boolean)
1038
+ .join(" ");
1039
+ const buttonLabels = buttons.map(buttonLabel).filter(Boolean);
987
1040
  const notes = [];
988
1041
  const findButton = (pattern) =>
989
1042
  buttons.find((button) => {
990
- const label = [
991
- button.getAttribute("aria-label"),
992
- button.getAttribute("data-tooltip"),
993
- text(button),
994
- ]
995
- .filter(Boolean)
996
- .join(" ");
1043
+ const label = buttonLabel(button);
997
1044
  return pattern.test(label) && !button.disabled;
998
1045
  });
1046
+ const findCallControlButton = (pattern) =>
1047
+ buttons.find((button) => {
1048
+ const label = buttonLabel(button);
1049
+ return pattern.test(label) && !/remotely mute|someone else/i.test(label) && !button.disabled;
1050
+ });
999
1051
  const input = [...document.querySelectorAll('input')].find((el) =>
1000
1052
  /your name/i.test(el.getAttribute('aria-label') || el.placeholder || '')
1001
1053
  );
1002
- if (${JSON.stringify(params.autoJoin)} && input && !input.value) {
1054
+ if (!readOnly && ${JSON.stringify(params.autoJoin)} && input && !input.value) {
1003
1055
  input.focus();
1004
1056
  input.value = ${JSON.stringify(params.guestName)};
1005
1057
  input.dispatchEvent(new Event('input', { bubbles: true }));
1006
1058
  input.dispatchEvent(new Event('change', { bubbles: true }));
1007
1059
  }
1008
1060
  const pageText = text(document.body).toLowerCase();
1061
+ const permissionText = [pageText, ...buttonLabels].join("\\n");
1009
1062
  const host = location.hostname.toLowerCase();
1010
1063
  const pageUrl = location.href;
1011
- const permissionNeeded = /permission needed|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(pageText);
1012
- const mic = buttons.find((button) => /turn off microphone|turn on microphone|microphone/i.test(button.getAttribute('aria-label') || text(button)));
1013
- if (!allowMicrophone && mic && /turn off microphone/i.test(mic.getAttribute('aria-label') || text(mic))) {
1064
+ const permissionNeeded = /permission needed|microphone problem|speaker problem|allow.*(microphone|camera)|blocked.*(microphone|camera)|permission.*(microphone|camera|speaker)/i.test(permissionText);
1065
+ let mic = findCallControlButton(/^\\s*turn (?:off|on) microphone\\b/i);
1066
+ if (!mic) {
1067
+ const callControls = document.querySelector('[role="region"][aria-label="Call controls"]');
1068
+ mic = [...(callControls?.querySelectorAll('button') || [])].find((button) =>
1069
+ /^\\s*turn (?:off|on) microphone\\b/i.test(buttonLabel(button))
1070
+ );
1071
+ }
1072
+ if (!readOnly && allowMicrophone && mic && /turn on microphone/i.test(buttonLabel(mic))) {
1073
+ mic.click();
1074
+ notes.push("Attempted to turn on the Meet microphone for realtime mode.");
1075
+ }
1076
+ if (!readOnly && !allowMicrophone && mic && /turn off microphone/i.test(mic.getAttribute('aria-label') || text(mic))) {
1014
1077
  mic.click();
1015
1078
  notes.push("Muted Meet microphone for observe-only mode.");
1016
1079
  }
1017
- const join = ${JSON.stringify(params.autoJoin)}
1080
+ const join = !readOnly && ${JSON.stringify(params.autoJoin)}
1018
1081
  ? findButton(/join now|ask to join/i)
1019
1082
  : null;
1020
1083
  if (join) join.click();
1021
1084
  const microphoneChoice = findButton(/\\buse microphone\\b/i);
1022
1085
  const noMicrophoneChoice = findButton(/\\b(continue|join|use) without (microphone|mic)\\b|\\bnot now\\b/i);
1023
- if (allowMicrophone && microphoneChoice) {
1086
+ if (!readOnly && allowMicrophone && microphoneChoice) {
1024
1087
  microphoneChoice.click();
1025
1088
  notes.push("Accepted Meet microphone prompt with browser automation.");
1026
- } else if (!allowMicrophone && noMicrophoneChoice) {
1089
+ } else if (!readOnly && !allowMicrophone && noMicrophoneChoice) {
1027
1090
  noMicrophoneChoice.click();
1028
1091
  notes.push("Skipped Meet microphone prompt for observe-only mode.");
1029
1092
  }
@@ -1078,7 +1141,7 @@ function meetStatusScript(params) {
1078
1141
  }
1079
1142
  };
1080
1143
  if (captionState) {
1081
- if (inCall && !captionState.enabledAttempted) {
1144
+ if (!readOnly && inCall && !captionState.enabledAttempted) {
1082
1145
  const captionButton = findButton(/turn on captions|show captions|captions/i);
1083
1146
  const captionLabel = captionButton ? (captionButton.getAttribute("aria-label") || captionButton.getAttribute("data-tooltip") || text(captionButton)) : "";
1084
1147
  if (captionButton) {
@@ -1140,7 +1203,7 @@ function meetStatusScript(params) {
1140
1203
  clickedJoin: Boolean(join),
1141
1204
  clickedMicrophoneChoice: Boolean(allowMicrophone && microphoneChoice),
1142
1205
  inCall,
1143
- micMuted: mic ? /turn on microphone/i.test(mic.getAttribute('aria-label') || text(mic)) : undefined,
1206
+ micMuted: mic ? /turn on microphone/i.test(buttonLabel(mic)) : undefined,
1144
1207
  lobbyWaiting,
1145
1208
  leaveReason,
1146
1209
  captioning,
@@ -1214,6 +1277,7 @@ async function openMeetWithBrowserRequest(params) {
1214
1277
  const permissionNotes = await grantMeetMediaPermissions({
1215
1278
  allowMicrophone: params.mode === "realtime",
1216
1279
  callBrowser: params.callBrowser,
1280
+ targetId,
1217
1281
  timeoutMs
1218
1282
  });
1219
1283
  const deadline = Date.now() + Math.max(0, params.config.chrome.waitForInCallMs);
@@ -1240,7 +1304,7 @@ async function openMeetWithBrowserRequest(params) {
1240
1304
  },
1241
1305
  timeoutMs: Math.min(timeoutMs, 1e4)
1242
1306
  })) ?? browser, permissionNotes);
1243
- if (browser?.inCall === true) return {
1307
+ if (browser?.inCall === true && (params.mode !== "realtime" || browser.micMuted !== true)) return {
1244
1308
  launched: true,
1245
1309
  browser
1246
1310
  };
@@ -1279,9 +1343,10 @@ async function inspectRecoverableMeetTab(params) {
1279
1343
  body: { targetId: params.targetId },
1280
1344
  timeoutMs: Math.min(params.timeoutMs, 5e3)
1281
1345
  });
1282
- const permissionNotes = await grantMeetMediaPermissions({
1346
+ const permissionNotes = params.readOnly ? [] : await grantMeetMediaPermissions({
1283
1347
  allowMicrophone,
1284
1348
  callBrowser: params.callBrowser,
1349
+ targetId: params.targetId,
1285
1350
  timeoutMs: params.timeoutMs
1286
1351
  });
1287
1352
  const browser = mergeBrowserNotes(parseMeetBrowserStatus(await params.callBrowser({
@@ -1294,7 +1359,8 @@ async function inspectRecoverableMeetTab(params) {
1294
1359
  allowMicrophone,
1295
1360
  captureCaptions: params.mode === "transcribe",
1296
1361
  guestName: params.config.chrome.guestName,
1297
- autoJoin: false
1362
+ autoJoin: false,
1363
+ readOnly: params.readOnly
1298
1364
  })
1299
1365
  },
1300
1366
  timeoutMs: Math.min(params.timeoutMs, 1e4)
@@ -1332,6 +1398,7 @@ async function recoverCurrentMeetTab(params) {
1332
1398
  callBrowser: callLocalBrowserRequest,
1333
1399
  config: params.config,
1334
1400
  mode: params.mode,
1401
+ readOnly: params.readOnly,
1335
1402
  timeoutMs,
1336
1403
  tab,
1337
1404
  targetId
@@ -1373,6 +1440,7 @@ async function recoverCurrentMeetTabOnNode(params) {
1373
1440
  }),
1374
1441
  config: params.config,
1375
1442
  mode: params.mode,
1443
+ readOnly: params.readOnly,
1376
1444
  timeoutMs,
1377
1445
  tab,
1378
1446
  targetId
@@ -2163,6 +2231,11 @@ function evaluateSpeechReadiness(session) {
2163
2231
  message: health.manualActionMessage ?? "Resolve the Google Meet browser prompt before asking OpenClaw to speak."
2164
2232
  };
2165
2233
  if (health?.inCall === true) {
2234
+ if (health.micMuted === true) return {
2235
+ ready: false,
2236
+ reason: "meet-microphone-muted",
2237
+ message: "Turn on the OpenClaw Google Meet microphone before asking OpenClaw to speak."
2238
+ };
2166
2239
  if (session.chrome.audioBridge) return { ready: true };
2167
2240
  return {
2168
2241
  ready: false,
@@ -2214,14 +2287,14 @@ var GoogleMeetRuntime = class {
2214
2287
  this.#refreshHealth(sessionId);
2215
2288
  if (!sessionId) {
2216
2289
  const sessions = [...this.#sessions.values()].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
2217
- await Promise.all(sessions.map((session) => this.#refreshCaptionHealthForSession(session)));
2290
+ await Promise.all(sessions.map((session) => this.#refreshStatusHealthForSession(session)));
2218
2291
  return {
2219
2292
  found: true,
2220
2293
  sessions
2221
2294
  };
2222
2295
  }
2223
2296
  const session = this.#sessions.get(sessionId);
2224
- if (session) await this.#refreshCaptionHealthForSession(session);
2297
+ if (session) await this.#refreshStatusHealthForSession(session);
2225
2298
  return session ? {
2226
2299
  found: true,
2227
2300
  session
@@ -2321,7 +2394,7 @@ var GoogleMeetRuntime = class {
2321
2394
  reusable.updatedAt = nowIso();
2322
2395
  return {
2323
2396
  session: reusable,
2324
- spoken: mode === "realtime" && speechInstructions ? (await this.speak(reusable.id, speechInstructions)).spoken : false
2397
+ spoken: mode === "realtime" && speechInstructions ? await this.#speakWhenReady(reusable, speechInstructions) : false
2325
2398
  };
2326
2399
  }
2327
2400
  const createdAt = nowIso();
@@ -2367,17 +2440,9 @@ var GoogleMeetRuntime = class {
2367
2440
  launched: result.launched,
2368
2441
  nodeId: "nodeId" in result ? result.nodeId : void 0,
2369
2442
  browserProfile: this.params.config.chrome.browserProfile,
2370
- audioBridge: result.audioBridge ? {
2371
- type: result.audioBridge.type,
2372
- provider: result.audioBridge.type === "command-pair" || result.audioBridge.type === "node-command-pair" ? result.audioBridge.providerId : void 0
2373
- } : void 0,
2374
2443
  health: "browser" in result ? result.browser : void 0
2375
2444
  };
2376
- if (result.audioBridge?.type === "command-pair" || result.audioBridge?.type === "node-command-pair") {
2377
- this.#sessionStops.set(session.id, result.audioBridge.stop);
2378
- this.#sessionSpeakers.set(session.id, result.audioBridge.speak);
2379
- this.#sessionHealth.set(session.id, result.audioBridge.getHealth);
2380
- }
2445
+ this.#attachChromeAudioBridge(session, result.audioBridge);
2381
2446
  session.notes.push(result.audioBridge ? transport === "chrome-node" ? "Chrome node transport joins as the signed-in Google profile on the selected node and routes realtime audio through the node bridge." : "Chrome transport joins as the signed-in Google profile and routes realtime audio through the configured bridge." : mode === "realtime" ? "Chrome transport joins as the signed-in Google profile and expects BlackHole 2ch audio routing." : "Chrome transport joins as the signed-in Google profile without starting the realtime audio bridge.");
2382
2447
  this.#refreshSpeechReadiness(session);
2383
2448
  } else {
@@ -2418,7 +2483,7 @@ var GoogleMeetRuntime = class {
2418
2483
  this.#sessions.set(session.id, session);
2419
2484
  return {
2420
2485
  session,
2421
- spoken: transport === "twilio" ? delegatedTwilioSpoken : mode === "realtime" && speechInstructions ? (await this.speak(session.id, speechInstructions)).spoken : false
2486
+ spoken: transport === "twilio" ? delegatedTwilioSpoken : mode === "realtime" && speechInstructions ? await this.#speakWhenReady(session, speechInstructions) : false
2422
2487
  };
2423
2488
  }
2424
2489
  async leave(sessionId) {
@@ -2459,6 +2524,7 @@ var GoogleMeetRuntime = class {
2459
2524
  };
2460
2525
  }
2461
2526
  await this.#refreshBrowserHealthForChromeSession(session);
2527
+ await this.#ensureChromeRealtimeBridge(session);
2462
2528
  const speak = this.#sessionSpeakers.get(sessionId);
2463
2529
  if (!speak || session.state !== "active") return {
2464
2530
  found: true,
@@ -2485,6 +2551,22 @@ var GoogleMeetRuntime = class {
2485
2551
  session
2486
2552
  };
2487
2553
  }
2554
+ async #speakWhenReady(session, instructions) {
2555
+ let result = await this.speak(session.id, instructions);
2556
+ if (result.spoken || session.transport === "twilio") return result.spoken;
2557
+ const waitMs = Math.min(Math.max(0, this.params.config.chrome.waitForInCallMs), Math.max(0, this.params.config.chrome.joinTimeoutMs));
2558
+ const deadline = Date.now() + waitMs;
2559
+ while (Date.now() < deadline) {
2560
+ await sleep(250);
2561
+ result = await this.speak(session.id, instructions);
2562
+ if (result.spoken) return true;
2563
+ const health = result.session?.chrome?.health;
2564
+ if (health?.manualActionRequired || result.session?.state !== "active") return false;
2565
+ const blocked = health?.speechBlockedReason;
2566
+ if (blocked && blocked !== "not-in-call" && blocked !== "browser-unverified") return false;
2567
+ }
2568
+ return false;
2569
+ }
2488
2570
  async testSpeech(request) {
2489
2571
  if (request.mode === "transcribe") throw new Error("test_speech requires mode: realtime; use join mode: transcribe for observe-only sessions.");
2490
2572
  const url = normalizeMeetUrl(request.url);
@@ -2581,12 +2663,26 @@ var GoogleMeetRuntime = class {
2581
2663
  }
2582
2664
  await this.#refreshBrowserHealthForChromeSession(session);
2583
2665
  }
2584
- async #refreshBrowserHealthForChromeSession(session) {
2666
+ async #refreshStatusHealthForSession(session) {
2667
+ if (session.transport === "chrome" || session.transport === "chrome-node") {
2668
+ if (session.chrome?.health?.manualActionRequired) {
2669
+ this.#refreshSpeechReadiness(session);
2670
+ return;
2671
+ }
2672
+ await this.#refreshBrowserHealthForChromeSession(session, {
2673
+ force: true,
2674
+ readOnly: true
2675
+ });
2676
+ return;
2677
+ }
2678
+ this.#refreshSpeechReadiness(session);
2679
+ }
2680
+ async #refreshBrowserHealthForChromeSession(session, options = {}) {
2585
2681
  if (!isManagedChromeBrowserSession(session)) {
2586
2682
  this.#refreshSpeechReadiness(session);
2587
2683
  return;
2588
2684
  }
2589
- if (session.mode === "realtime" && evaluateSpeechReadiness(session).ready) {
2685
+ if (!options.force && session.mode === "realtime" && evaluateSpeechReadiness(session).ready) {
2590
2686
  this.#refreshSpeechReadiness(session);
2591
2687
  return;
2592
2688
  }
@@ -2595,10 +2691,12 @@ var GoogleMeetRuntime = class {
2595
2691
  runtime: this.params.runtime,
2596
2692
  config: this.params.config,
2597
2693
  mode: session.mode,
2694
+ readOnly: options.readOnly,
2598
2695
  url: session.url
2599
2696
  }) : await recoverCurrentMeetTab({
2600
2697
  config: this.params.config,
2601
2698
  mode: session.mode,
2699
+ readOnly: options.readOnly,
2602
2700
  url: session.url
2603
2701
  });
2604
2702
  if (result.found && result.browser && session.chrome) {
@@ -2613,8 +2711,43 @@ var GoogleMeetRuntime = class {
2613
2711
  }
2614
2712
  this.#refreshSpeechReadiness(session);
2615
2713
  }
2714
+ #attachChromeAudioBridge(session, audioBridge) {
2715
+ if (!session.chrome || !audioBridge) return;
2716
+ session.chrome.audioBridge = {
2717
+ type: audioBridge.type,
2718
+ provider: audioBridge.type === "command-pair" || audioBridge.type === "node-command-pair" ? audioBridge.providerId : void 0
2719
+ };
2720
+ if (audioBridge.type === "command-pair" || audioBridge.type === "node-command-pair") {
2721
+ this.#sessionStops.set(session.id, audioBridge.stop);
2722
+ this.#sessionSpeakers.set(session.id, audioBridge.speak);
2723
+ this.#sessionHealth.set(session.id, audioBridge.getHealth);
2724
+ }
2725
+ }
2726
+ async #ensureChromeRealtimeBridge(session) {
2727
+ if (session.mode !== "realtime" || session.transport !== "chrome" || session.state !== "active" || !session.chrome || session.chrome.audioBridge) return;
2728
+ const health = session.chrome.health;
2729
+ if (health?.inCall !== true || health.micMuted === true || health.manualActionRequired === true) return;
2730
+ const result = await launchChromeMeet({
2731
+ runtime: this.params.runtime,
2732
+ config: {
2733
+ ...this.params.config,
2734
+ chrome: {
2735
+ ...this.params.config.chrome,
2736
+ launch: false
2737
+ }
2738
+ },
2739
+ fullConfig: this.params.fullConfig,
2740
+ meetingSessionId: session.id,
2741
+ mode: session.mode,
2742
+ url: session.url,
2743
+ logger: this.params.logger
2744
+ });
2745
+ this.#attachChromeAudioBridge(session, result.audioBridge);
2746
+ session.updatedAt = nowIso();
2747
+ }
2616
2748
  #refreshSpeechReadiness(session) {
2617
2749
  const readiness = evaluateSpeechReadiness(session);
2750
+ if (readiness.ready) session.notes = session.notes.filter((note) => !note.startsWith("Realtime speech blocked:"));
2618
2751
  if (session.chrome) session.chrome.health = {
2619
2752
  ...session.chrome.health,
2620
2753
  speechReady: readiness.ready,
@@ -3080,7 +3213,7 @@ async function exportGoogleMeetBundleFromParams(config, raw) {
3080
3213
  lateAfterMinutes: resolved.lateAfterMinutes,
3081
3214
  earlyBeforeMinutes: resolved.earlyBeforeMinutes
3082
3215
  })]);
3083
- const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = await import("./cli-CSyT9_N6.js");
3216
+ const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = await import("./cli-B_wJa8XB.js");
3084
3217
  const calendarId = normalizeOptionalString(raw.calendarId);
3085
3218
  const request = {
3086
3219
  ...resolved.meeting ? { meeting: resolved.meeting } : {},
@@ -3489,7 +3622,7 @@ var google_meet_default = definePluginEntry({
3489
3622
  handle: handleGoogleMeetNodeHostCommand
3490
3623
  });
3491
3624
  api.registerCli(async ({ program }) => {
3492
- const { registerGoogleMeetCli } = await import("./cli-CSyT9_N6.js");
3625
+ const { registerGoogleMeetCli } = await import("./cli-B_wJa8XB.js");
3493
3626
  registerGoogleMeetCli({
3494
3627
  program,
3495
3628
  config,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/google-meet",
3
- "version": "2026.5.3-beta.2",
3
+ "version": "2026.5.3",
4
4
  "description": "OpenClaw Google Meet participant plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,7 +16,7 @@
16
16
  "openclaw": "workspace:*"
17
17
  },
18
18
  "peerDependencies": {
19
- "openclaw": ">=2026.5.3-beta.2"
19
+ "openclaw": ">=2026.5.3"
20
20
  },
21
21
  "peerDependenciesMeta": {
22
22
  "openclaw": {
@@ -33,10 +33,10 @@
33
33
  "minHostVersion": ">=2026.4.20"
34
34
  },
35
35
  "compat": {
36
- "pluginApi": ">=2026.5.3-beta.2"
36
+ "pluginApi": ">=2026.5.3"
37
37
  },
38
38
  "build": {
39
- "openclawVersion": "2026.5.3-beta.2"
39
+ "openclawVersion": "2026.5.3"
40
40
  },
41
41
  "release": {
42
42
  "publishToClawHub": true,