@openclaw/voice-call 2026.7.2-beta.7 → 2026.8.1-beta.2

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.
package/dist/index.js CHANGED
@@ -1,18 +1,19 @@
1
1
  import { definePluginEntry, sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
4
- import { S as validateProviderConfig, b as resolveVoiceCallConfig, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoute, g as resolveUserPath, l as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime, u as getTailscaleSelfInfo, y as VoiceCallConfigSchema } from "./runtime-entry-D0UJiLfi.js";
5
- import { g as setVoiceCallStateRuntime, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-nYL-yM0S.js";
4
+ import { S as validateProviderConfig, b as resolveVoiceCallConfig, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoute, g as resolveUserPath, l as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime, u as getTailscaleSelfInfo, y as VoiceCallConfigSchema } from "./runtime-entry-F13eJ4pp.js";
5
+ import { b as setVoiceCallStateRuntime, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-51_oC_xn.js";
6
6
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7
7
  import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
8
- import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
9
- import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
10
- import { asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
8
+ import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
9
+ import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
10
+ import { asNonArrayRecord, asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
11
11
  import { jsonResult } from "openclaw/plugin-sdk/tool-results";
12
12
  import { Type } from "typebox";
13
13
  import fs from "node:fs";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import path from "node:path";
16
+ import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
16
17
  import { StringDecoder } from "node:string_decoder";
17
18
  import { format } from "node:util";
18
19
  //#region extensions/voice-call/src/cli.ts
@@ -543,6 +544,126 @@ function registerVoiceCallCli(params) {
543
544
  });
544
545
  }
545
546
  //#endregion
547
+ //#region extensions/voice-call/src/command-service.ts
548
+ var VoiceCallCommandInputError = class extends Error {};
549
+ function toVoiceCallStatus(call) {
550
+ return {
551
+ callId: call.callId,
552
+ ...call.providerCallId !== void 0 ? { providerCallId: call.providerCallId } : {},
553
+ provider: call.provider,
554
+ direction: call.direction,
555
+ state: call.state,
556
+ startedAt: call.startedAt,
557
+ ...call.answeredAt !== void 0 ? { answeredAt: call.answeredAt } : {},
558
+ ...call.endedAt !== void 0 ? { endedAt: call.endedAt } : {},
559
+ ...call.endReason !== void 0 ? { endReason: call.endReason } : {}
560
+ };
561
+ }
562
+ function requireInput(value, message) {
563
+ if (!value) throw new VoiceCallCommandInputError(message);
564
+ return value;
565
+ }
566
+ function requireSuccess(result, fallback) {
567
+ if (!result.success) throw new Error(result.error || fallback);
568
+ }
569
+ function createVoiceCallCommandService(ensureRuntime) {
570
+ const describeHistoricalCall = async (rt, callId) => {
571
+ const call = await rt.manager.getCallFromMemoryOrStore(callId);
572
+ if (!call) return;
573
+ const endedAt = timestampMsToIsoString(call.endedAt);
574
+ return `call is not active (${[
575
+ `last state=${call.state}`,
576
+ call.endReason ? `endReason=${call.endReason}` : void 0,
577
+ endedAt ? `endedAt=${endedAt}` : void 0
578
+ ].filter(Boolean).join(", ")})`;
579
+ };
580
+ const resolveCallMessage = async (callId, message) => {
581
+ const resolvedCallId = requireInput(callId, "callId and message required");
582
+ const resolvedMessage = requireInput(message, "callId and message required");
583
+ const rt = await ensureRuntime();
584
+ const activeCall = rt.manager.getCall(resolvedCallId) ?? rt.manager.getCallByProviderCallId(resolvedCallId);
585
+ if (!activeCall) throw new VoiceCallCommandInputError(await describeHistoricalCall(rt, resolvedCallId) ?? "Call not found");
586
+ return {
587
+ rt,
588
+ callId: activeCall.callId,
589
+ message: resolvedMessage
590
+ };
591
+ };
592
+ const prepareContinue = async (callId, message) => {
593
+ const request = await resolveCallMessage(callId, message);
594
+ return {
595
+ rt: request.rt,
596
+ callId: request.callId,
597
+ run: async () => {
598
+ const result = await request.rt.manager.continueCall(request.callId, request.message);
599
+ requireSuccess(result, "continue failed");
600
+ return {
601
+ success: true,
602
+ transcript: result.transcript
603
+ };
604
+ }
605
+ };
606
+ };
607
+ return {
608
+ prepareContinue,
609
+ async initiate(params, missingToMessage = "to required") {
610
+ const rt = await ensureRuntime();
611
+ const to = requireInput(params.to ?? rt.config.toNumber, missingToMessage);
612
+ const result = await rt.manager.initiateCall(to, params.sessionKey, {
613
+ message: params.message,
614
+ mode: params.mode,
615
+ dtmfSequence: params.dtmfSequence,
616
+ ...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
617
+ ...params.agentId ? { agentId: params.agentId } : {}
618
+ });
619
+ requireSuccess(result, "initiate failed");
620
+ return {
621
+ callId: result.callId,
622
+ initiated: true
623
+ };
624
+ },
625
+ async continueCall(callId, message) {
626
+ return await (await prepareContinue(callId, message)).run();
627
+ },
628
+ async speak(params) {
629
+ const request = await resolveCallMessage(params.callId, params.message);
630
+ if (request.rt.config.realtime.enabled) {
631
+ const realtimeResult = request.rt.webhookServer.speakRealtime(request.callId, request.message);
632
+ if (realtimeResult.success) return { success: true };
633
+ if (params.allowTwimlFallback === false) return {
634
+ success: false,
635
+ error: realtimeResult.error ?? "Realtime bridge is not active"
636
+ };
637
+ }
638
+ requireSuccess(await request.rt.manager.speak(request.callId, request.message), "speak failed");
639
+ return { success: true };
640
+ },
641
+ async sendDtmf(callId, digits) {
642
+ const resolvedCallId = requireInput(callId, "callId and digits required");
643
+ const resolvedDigits = requireInput(digits, "callId and digits required");
644
+ requireSuccess(await (await ensureRuntime()).manager.sendDtmf(resolvedCallId, resolvedDigits), "dtmf failed");
645
+ return { success: true };
646
+ },
647
+ async endCall(callId) {
648
+ const resolvedCallId = requireInput(callId, "callId required");
649
+ requireSuccess(await (await ensureRuntime()).manager.endCall(resolvedCallId), "end failed");
650
+ return { success: true };
651
+ },
652
+ async status(callId) {
653
+ const rt = await ensureRuntime();
654
+ if (!callId) return {
655
+ found: true,
656
+ calls: rt.manager.getActiveCalls().map(toVoiceCallStatus)
657
+ };
658
+ const call = await rt.manager.getCallFromMemoryOrStore(callId);
659
+ return call ? {
660
+ found: true,
661
+ call: toVoiceCallStatus(call)
662
+ } : { found: false };
663
+ }
664
+ };
665
+ }
666
+ //#endregion
546
667
  //#region extensions/voice-call/src/gateway-continue-operation.ts
547
668
  const VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS = 3e4;
548
669
  const VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS = 300 * 1e3;
@@ -569,21 +690,9 @@ function createVoiceCallContinueOperationStore(params) {
569
690
  startedAtMs,
570
691
  pollTimeoutMs
571
692
  });
572
- request.rt.manager.continueCall(request.callId, request.message).then((result) => {
693
+ request.run().then((result) => {
573
694
  const current = operations.get(operationId);
574
695
  if (!current || current.status !== "pending") return;
575
- if (!result.success) {
576
- operations.set(operationId, {
577
- operationId,
578
- status: "failed",
579
- callId: request.callId,
580
- startedAtMs,
581
- completedAtMs: Date.now(),
582
- pollTimeoutMs,
583
- error: result.error || "continue failed"
584
- });
585
- return;
586
- }
587
696
  operations.set(operationId, {
588
697
  operationId,
589
698
  status: "completed",
@@ -661,223 +770,15 @@ function createVoiceCallContinueOperationStore(params) {
661
770
  //#region extensions/voice-call/index.ts
662
771
  const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" };
663
772
  const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" };
664
- const voiceCallConfigSchema = {
665
- parse(value) {
666
- const config = asOptionalRecord(value) ?? {};
667
- const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
668
- return VoiceCallConfigSchema.parse({
669
- ...config,
670
- enabled,
671
- provider: config.provider ?? (enabled ? "mock" : void 0)
672
- });
673
- },
674
- uiHints: {
675
- provider: {
676
- label: "Provider",
677
- help: "Use twilio, telnyx, or mock for dev/no-network."
678
- },
679
- fromNumber: {
680
- label: "From Number",
681
- placeholder: "+15550001234"
682
- },
683
- toNumber: {
684
- label: "Default To Number",
685
- placeholder: "+15550001234"
686
- },
687
- inboundPolicy: { label: "Inbound Policy" },
688
- allowFrom: { label: "Inbound Allowlist" },
689
- inboundGreeting: {
690
- label: "Inbound Greeting",
691
- advanced: true
692
- },
693
- numbers: {
694
- label: "Per-number Routing",
695
- help: "Inbound overrides keyed by dialed E.164 number.",
696
- advanced: true
697
- },
698
- "telnyx.apiKey": {
699
- label: "Telnyx API Key",
700
- sensitive: true
701
- },
702
- "telnyx.connectionId": { label: "Telnyx Connection ID" },
703
- "telnyx.publicKey": {
704
- label: "Telnyx Public Key",
705
- sensitive: true
706
- },
707
- "twilio.accountSid": { label: "Twilio Account SID" },
708
- "twilio.authToken": {
709
- label: "Twilio Auth Token",
710
- sensitive: true
711
- },
712
- "twilio.region": {
713
- label: "Twilio Region",
714
- advanced: true
715
- },
716
- "outbound.defaultMode": { label: "Default Call Mode" },
717
- "outbound.notifyHangupDelaySec": {
718
- label: "Notify Hangup Delay (sec)",
719
- advanced: true
720
- },
721
- "serve.port": { label: "Webhook Port" },
722
- "serve.bind": { label: "Webhook Bind" },
723
- "serve.path": { label: "Webhook Path" },
724
- "tailscale.mode": {
725
- label: "Tailscale Mode",
726
- advanced: true
727
- },
728
- "tailscale.path": {
729
- label: "Tailscale Path",
730
- advanced: true
731
- },
732
- "tunnel.provider": {
733
- label: "Tunnel Provider",
734
- advanced: true
735
- },
736
- "tunnel.ngrokAuthToken": {
737
- label: "ngrok Auth Token",
738
- sensitive: true,
739
- advanced: true
740
- },
741
- "tunnel.ngrokDomain": {
742
- label: "ngrok Domain",
743
- advanced: true
744
- },
745
- "tunnel.allowNgrokFreeTierLoopbackBypass": {
746
- label: "Allow ngrok Free Tier (Loopback Bypass)",
747
- advanced: true
748
- },
749
- "streaming.enabled": {
750
- label: "Enable Streaming",
751
- help: "Classic streaming transcription currently requires the Twilio call provider.",
752
- advanced: true
753
- },
754
- "streaming.provider": {
755
- label: "Streaming Provider",
756
- help: "Uses the first registered realtime transcription provider when unset.",
757
- advanced: true
758
- },
759
- "streaming.providers": {
760
- label: "Streaming Provider Config",
761
- advanced: true
762
- },
763
- "streaming.streamPath": {
764
- label: "Media Stream Path",
765
- advanced: true
766
- },
767
- "realtime.enabled": {
768
- label: "Enable Realtime Voice",
769
- advanced: true
770
- },
771
- "realtime.provider": {
772
- label: "Realtime Voice Provider",
773
- help: "Uses the first registered realtime voice provider when unset.",
774
- advanced: true
775
- },
776
- "realtime.streamPath": {
777
- label: "Realtime Stream Path",
778
- advanced: true
779
- },
780
- "realtime.instructions": {
781
- label: "Realtime Instructions",
782
- advanced: true
783
- },
784
- "realtime.toolPolicy": {
785
- label: "Realtime Tool Policy",
786
- help: "Controls the shared openclaw_agent_consult tool.",
787
- advanced: true
788
- },
789
- "realtime.consultPolicy": {
790
- label: "Realtime Consult Policy",
791
- help: "Guides when the realtime voice model should call openclaw_agent_consult.",
792
- advanced: true
793
- },
794
- "realtime.fastContext.enabled": {
795
- label: "Enable Fast Realtime Context",
796
- help: "Searches memory/session context before the full consult agent.",
797
- advanced: true
798
- },
799
- "realtime.fastContext.timeoutMs": {
800
- label: "Fast Context Timeout",
801
- advanced: true
802
- },
803
- "realtime.fastContext.maxResults": {
804
- label: "Fast Context Result Limit",
805
- advanced: true
806
- },
807
- "realtime.fastContext.sources": {
808
- label: "Fast Context Sources",
809
- advanced: true
810
- },
811
- "realtime.fastContext.fallbackToConsult": {
812
- label: "Fallback To Full Consult",
813
- advanced: true
814
- },
815
- "realtime.agentContext.enabled": {
816
- label: "Enable Agent Voice Context",
817
- help: "Injects a compact agent identity and workspace context capsule into realtime voice instructions.",
818
- advanced: true
819
- },
820
- "realtime.agentContext.maxChars": {
821
- label: "Agent Voice Context Limit",
822
- advanced: true
823
- },
824
- "realtime.agentContext.includeIdentity": {
825
- label: "Include Agent Identity",
826
- advanced: true
827
- },
828
- "realtime.agentContext.includeWorkspaceFiles": {
829
- label: "Include Agent Workspace Files",
830
- advanced: true
831
- },
832
- "realtime.agentContext.files": {
833
- label: "Agent Voice Context Files",
834
- advanced: true
835
- },
836
- "realtime.providers": {
837
- label: "Realtime Provider Config",
838
- advanced: true
839
- },
840
- "tts.provider": {
841
- label: "TTS Provider Override",
842
- help: "Deep-merges with tts (Microsoft is ignored for calls).",
843
- advanced: true
844
- },
845
- "tts.providers": {
846
- label: "TTS Provider Config",
847
- advanced: true
848
- },
849
- publicUrl: {
850
- label: "Public Webhook URL",
851
- advanced: true
852
- },
853
- skipSignatureVerification: {
854
- label: "Skip Signature Verification",
855
- advanced: true
856
- },
857
- store: {
858
- label: "Call Log Store Path",
859
- advanced: true
860
- },
861
- agentId: {
862
- label: "Response Agent ID",
863
- help: "Agent workspace used for voice response generation. Defaults to \"main\".",
864
- advanced: true
865
- },
866
- responseModel: {
867
- label: "Response Model",
868
- help: "Optional override. Falls back to the runtime default model when unset.",
869
- advanced: true
870
- },
871
- responseSystemPrompt: {
872
- label: "Response System Prompt",
873
- advanced: true
874
- },
875
- responseTimeoutMs: {
876
- label: "Response Timeout (ms)",
877
- advanced: true
878
- }
879
- }
880
- };
773
+ const voiceCallConfigSchema = { parse(value) {
774
+ const config = asOptionalRecord(value) ?? {};
775
+ const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
776
+ return VoiceCallConfigSchema.parse({
777
+ ...config,
778
+ enabled,
779
+ provider: config.provider ?? (enabled ? "mock" : void 0)
780
+ });
781
+ } };
881
782
  const VoiceCallToolSchema = Type.Union([
882
783
  Type.Object({
883
784
  action: Type.Literal("initiate_call"),
@@ -885,7 +786,6 @@ const VoiceCallToolSchema = Type.Union([
885
786
  message: Type.String({ description: "Intro message" }),
886
787
  mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
887
788
  sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
888
- requesterSessionKey: Type.Optional(Type.String({ description: "OpenClaw session key that initiated the call" })),
889
789
  dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
890
790
  }),
891
791
  Type.Object({
@@ -917,38 +817,28 @@ const VoiceCallToolSchema = Type.Union([
917
817
  sid: Type.Optional(Type.String({ description: "Call SID" })),
918
818
  message: Type.Optional(Type.String({ description: "Optional intro message" })),
919
819
  sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
920
- requesterSessionKey: Type.Optional(Type.String({ description: "OpenClaw session key that initiated the call" })),
921
820
  dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
922
821
  })
923
822
  ]);
924
- function asParamRecord(params) {
925
- return params && typeof params === "object" && !Array.isArray(params) ? params : {};
926
- }
927
823
  function isCliOnlyProcess() {
928
824
  return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
929
825
  }
930
- function toVoiceCallStatus(call) {
931
- return {
932
- callId: call.callId,
933
- ...call.providerCallId !== void 0 ? { providerCallId: call.providerCallId } : {},
934
- provider: call.provider,
935
- direction: call.direction,
936
- state: call.state,
937
- startedAt: call.startedAt,
938
- ...call.answeredAt !== void 0 ? { answeredAt: call.answeredAt } : {},
939
- ...call.endedAt !== void 0 ? { endedAt: call.endedAt } : {},
940
- ...call.endReason !== void 0 ? { endReason: call.endReason } : {}
941
- };
826
+ const VOICE_CALL_RUNTIME_COORDINATOR_KEY = Symbol.for("openclaw.voice-call.runtimeCoordinator");
827
+ var VoiceCallRuntimeLifecycleError = class extends Error {};
828
+ function getVoiceCallRuntimeCoordinator() {
829
+ return resolveGlobalSingleton(VOICE_CALL_RUNTIME_COORDINATOR_KEY, () => ({
830
+ epochCounter: 0,
831
+ registeredEpoch: 0
832
+ }));
942
833
  }
943
- const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime");
944
- const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise");
945
- const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise");
946
- function getVoiceCallRuntimeGlobalState() {
947
- const state = globalThis;
948
- state[VOICE_CALL_RUNTIME_KEY] ??= null;
949
- state[VOICE_CALL_RUNTIME_PROMISE_KEY] ??= null;
950
- state[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] ??= null;
951
- return state;
834
+ function activateVoiceCallRuntimeGeneration(coordinator, generation) {
835
+ if (generation.epoch < coordinator.registeredEpoch) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
836
+ if (generation.retired) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation is retired; use the current plugin registration");
837
+ if (coordinator.current !== generation) {
838
+ if (coordinator.current) coordinator.current.retired = true;
839
+ coordinator.current = generation;
840
+ coordinator.registeredEpoch = generation.epoch;
841
+ }
952
842
  }
953
843
  var voice_call_default = definePluginEntry({
954
844
  id: "voice-call",
@@ -958,304 +848,122 @@ var voice_call_default = definePluginEntry({
958
848
  register(api) {
959
849
  const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
960
850
  const validation = validateProviderConfig(config);
961
- const runtimeState = getVoiceCallRuntimeGlobalState();
851
+ const runtimeCoordinator = getVoiceCallRuntimeCoordinator();
852
+ const runtimeGeneration = api.registrationMode !== "full" && runtimeCoordinator.current ? runtimeCoordinator.current : {
853
+ epoch: ++runtimeCoordinator.epochCounter,
854
+ retired: false
855
+ };
962
856
  const continueOperationStore = createVoiceCallContinueOperationStore({
963
857
  config,
964
858
  coreConfig: api.config
965
859
  });
966
860
  const ensureRuntime = async () => {
861
+ activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
967
862
  if (!config.enabled) throw new Error("Voice call disabled in plugin config");
968
863
  if (!validation.valid) throw new Error(validation.errors.join("; "));
969
864
  while (true) {
970
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
971
- await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
972
- continue;
973
- }
974
- const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
975
- if (runtime) return runtime;
976
- let runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
977
- if (!runtimePromise) {
978
- runtimePromise = createVoiceCallRuntime({
979
- config,
980
- coreConfig: api.config,
981
- fullConfig: api.config,
982
- agentRuntime: api.runtime.agent,
983
- stateRuntime: api.runtime.state,
984
- ttsRuntime: api.runtime.tts,
985
- logger: api.logger
986
- });
987
- runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = runtimePromise;
988
- }
989
- try {
990
- const createdRuntime = await runtimePromise;
991
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) continue;
992
- if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] !== runtimePromise) continue;
993
- runtimeState[VOICE_CALL_RUNTIME_KEY] = createdRuntime;
994
- return createdRuntime;
995
- } catch (err) {
996
- if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] === runtimePromise) {
997
- runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
998
- runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
865
+ activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
866
+ const slot = runtimeCoordinator.slot;
867
+ if (slot) {
868
+ if (slot.owner !== runtimeGeneration) {
869
+ if (slot.state === "stopping") {
870
+ await slot.promise;
871
+ continue;
872
+ }
873
+ throw new VoiceCallRuntimeLifecycleError("A previous voice call runtime generation is still active; retry after it stops");
999
874
  }
1000
- throw err;
1001
- }
1002
- }
1003
- };
1004
- const respondError = (respond, message, code = ErrorCodes.UNAVAILABLE) => {
1005
- respond(false, void 0, errorShape(code, message));
1006
- };
1007
- const sendError = (respond, err) => {
1008
- respondError(respond, formatErrorMessage(err));
1009
- };
1010
- const describeHistoricalCall = async (rt, callId) => {
1011
- const call = await rt.manager.getCallFromMemoryOrStore(callId);
1012
- if (!call) return;
1013
- const endedAt = timestampMsToIsoString(call.endedAt);
1014
- return `call is not active (${[
1015
- `last state=${call.state}`,
1016
- call.endReason ? `endReason=${call.endReason}` : void 0,
1017
- endedAt ? `endedAt=${endedAt}` : void 0
1018
- ].filter(Boolean).join(", ")})`;
1019
- };
1020
- const resolveCallMessageRequest = async (params) => {
1021
- const callId = normalizeOptionalString(params?.callId) ?? "";
1022
- const message = normalizeOptionalString(params?.message) ?? "";
1023
- if (!callId || !message) return { error: "callId and message required" };
1024
- const rt = await ensureRuntime();
1025
- const activeCall = rt.manager.getCall(callId) ?? rt.manager.getCallByProviderCallId(callId);
1026
- if (activeCall) return {
1027
- rt,
1028
- callId: activeCall.callId,
1029
- message
1030
- };
1031
- return { error: await describeHistoricalCall(rt, callId) ?? "Call not found" };
1032
- };
1033
- const initiateCallAndRespond = async (params) => {
1034
- const result = await params.rt.manager.initiateCall(params.to, params.sessionKey, {
1035
- message: params.message,
1036
- mode: params.mode,
1037
- dtmfSequence: params.dtmfSequence,
1038
- ...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
1039
- ...params.agentId ? { agentId: params.agentId } : {}
1040
- });
1041
- if (!result.success) {
1042
- respondError(params.respond, result.error || "initiate failed");
1043
- return;
1044
- }
1045
- params.respond(true, {
1046
- callId: result.callId,
1047
- initiated: true
1048
- });
1049
- };
1050
- const respondToCallMessageAction = async (params) => {
1051
- const request = await resolveCallMessageRequest(params.requestParams);
1052
- if ("error" in request) {
1053
- respondError(params.respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
1054
- return;
1055
- }
1056
- const result = await params.action(request);
1057
- if (!result.success) {
1058
- respondError(params.respond, result.error || params.failure);
1059
- return;
1060
- }
1061
- params.respond(true, params.includeTranscript ? {
1062
- success: true,
1063
- transcript: result.transcript
1064
- } : { success: true });
1065
- };
1066
- api.registerGatewayMethod("voicecall.initiate", async ({ params, respond }) => {
1067
- try {
1068
- const message = normalizeOptionalString(params?.message) ?? "";
1069
- if (!message) {
1070
- respondError(respond, "message required", ErrorCodes.INVALID_REQUEST);
1071
- return;
1072
- }
1073
- const rt = await ensureRuntime();
1074
- const to = normalizeOptionalString(params?.to) ?? rt.config.toNumber;
1075
- if (!to) {
1076
- respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
1077
- return;
1078
- }
1079
- const mode = params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0;
1080
- await initiateCallAndRespond({
1081
- rt,
1082
- respond,
1083
- to,
1084
- message,
1085
- mode,
1086
- sessionKey: normalizeOptionalString(params?.sessionKey),
1087
- requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey)
1088
- });
1089
- } catch (err) {
1090
- sendError(respond, err);
1091
- }
1092
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1093
- api.registerGatewayMethod("voicecall.continue", async ({ params, respond }) => {
1094
- try {
1095
- await respondToCallMessageAction({
1096
- requestParams: params,
1097
- respond,
1098
- action: (request) => request.rt.manager.continueCall(request.callId, request.message),
1099
- failure: "continue failed",
1100
- includeTranscript: true
1101
- });
1102
- } catch (err) {
1103
- sendError(respond, err);
1104
- }
1105
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1106
- api.registerGatewayMethod("voicecall.continue.start", async ({ params, respond }) => {
1107
- try {
1108
- const request = await resolveCallMessageRequest(params);
1109
- if ("error" in request) {
1110
- respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
1111
- return;
1112
- }
1113
- respond(true, continueOperationStore.start(request));
1114
- } catch (err) {
1115
- sendError(respond, err);
1116
- }
1117
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1118
- api.registerGatewayMethod("voicecall.continue.result", async ({ params, respond }) => {
1119
- try {
1120
- const operationId = normalizeOptionalString(params?.operationId) ?? "";
1121
- if (!operationId) {
1122
- respondError(respond, "operationId required", ErrorCodes.INVALID_REQUEST);
1123
- return;
1124
- }
1125
- const operation = continueOperationStore.read(operationId);
1126
- if (!operation.ok) {
1127
- respondError(respond, operation.error, ErrorCodes.INVALID_REQUEST);
1128
- return;
1129
- }
1130
- respond(true, operation.payload);
1131
- } catch (err) {
1132
- sendError(respond, err);
1133
- }
1134
- }, VOICE_CALL_READ_METHOD_SCOPE);
1135
- api.registerGatewayMethod("voicecall.speak", async ({ params, respond }) => {
1136
- try {
1137
- const request = await resolveCallMessageRequest(params);
1138
- if ("error" in request) {
1139
- respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
1140
- return;
1141
- }
1142
- if (request.rt.config.realtime.enabled) {
1143
- const realtimeResult = request.rt.webhookServer.speakRealtime(request.callId, request.message);
1144
- if (realtimeResult.success) {
1145
- respond(true, { success: true });
1146
- return;
875
+ if (slot.state === "running") return slot.runtime;
876
+ if (slot.state === "stopping") {
877
+ await slot.promise;
878
+ continue;
1147
879
  }
1148
- if (params?.allowTwimlFallback === false) {
1149
- respond(true, {
1150
- success: false,
1151
- error: realtimeResult.error ?? "Realtime bridge is not active"
1152
- });
1153
- return;
880
+ let createdRuntime;
881
+ try {
882
+ createdRuntime = await slot.promise;
883
+ } catch (err) {
884
+ if (runtimeCoordinator.slot === slot) runtimeCoordinator.slot = void 0;
885
+ throw err;
1154
886
  }
887
+ activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
888
+ if (runtimeCoordinator.slot !== slot) continue;
889
+ runtimeCoordinator.slot = {
890
+ state: "running",
891
+ owner: runtimeGeneration,
892
+ runtime: createdRuntime
893
+ };
894
+ return createdRuntime;
1155
895
  }
1156
- const result = await request.rt.manager.speak(request.callId, request.message);
1157
- if (!result.success) {
1158
- respondError(respond, result.error || "speak failed");
1159
- return;
1160
- }
1161
- respond(true, { success: true });
1162
- } catch (err) {
1163
- sendError(respond, err);
1164
- }
1165
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1166
- api.registerGatewayMethod("voicecall.dtmf", async ({ params, respond }) => {
1167
- try {
1168
- const callId = normalizeOptionalString(params?.callId) ?? "";
1169
- const digits = normalizeOptionalString(params?.digits) ?? "";
1170
- if (!callId || !digits) {
1171
- respondError(respond, "callId and digits required", ErrorCodes.INVALID_REQUEST);
1172
- return;
1173
- }
1174
- const result = await (await ensureRuntime()).manager.sendDtmf(callId, digits);
1175
- if (!result.success) {
1176
- respondError(respond, result.error || "dtmf failed");
1177
- return;
1178
- }
1179
- respond(true, { success: true });
1180
- } catch (err) {
1181
- sendError(respond, err);
896
+ const runtimePromise = createVoiceCallRuntime({
897
+ config,
898
+ coreConfig: api.config,
899
+ fullConfig: api.config,
900
+ agentRuntime: api.runtime.agent,
901
+ stateRuntime: api.runtime.state,
902
+ ttsRuntime: api.runtime.tts,
903
+ logger: api.logger
904
+ });
905
+ runtimeCoordinator.slot = {
906
+ state: "starting",
907
+ owner: runtimeGeneration,
908
+ promise: runtimePromise
909
+ };
1182
910
  }
1183
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1184
- api.registerGatewayMethod("voicecall.end", async ({ params, respond }) => {
1185
- try {
1186
- const callId = normalizeOptionalString(params?.callId) ?? "";
1187
- if (!callId) {
1188
- respondError(respond, "callId required", ErrorCodes.INVALID_REQUEST);
1189
- return;
1190
- }
1191
- const result = await (await ensureRuntime()).manager.endCall(callId);
1192
- if (!result.success) {
1193
- respondError(respond, result.error || "end failed");
1194
- return;
911
+ };
912
+ const commands = createVoiceCallCommandService(ensureRuntime);
913
+ const registerGatewayCommand = (method, handler, scope) => {
914
+ api.registerGatewayMethod(method, async (options) => {
915
+ try {
916
+ options.respond(true, await handler(options));
917
+ } catch (err) {
918
+ const code = err instanceof VoiceCallCommandInputError ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE;
919
+ options.respond(false, void 0, errorShape(code, formatErrorMessage(err)));
1195
920
  }
1196
- respond(true, { success: true });
1197
- } catch (err) {
1198
- sendError(respond, err);
1199
- }
921
+ }, scope);
922
+ };
923
+ registerGatewayCommand("voicecall.initiate", async ({ params }) => {
924
+ const message = normalizeOptionalString(params?.message);
925
+ if (!message) throw new VoiceCallCommandInputError("message required");
926
+ return await commands.initiate({
927
+ to: normalizeOptionalString(params?.to),
928
+ message,
929
+ mode: params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0,
930
+ sessionKey: normalizeOptionalString(params?.sessionKey),
931
+ requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey)
932
+ });
1200
933
  }, VOICE_CALL_WRITE_METHOD_SCOPE);
1201
- api.registerGatewayMethod("voicecall.status", async ({ params, respond }) => {
1202
- try {
1203
- const raw = normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? "";
1204
- const rt = await ensureRuntime();
1205
- if (!raw) {
1206
- respond(true, {
1207
- found: true,
1208
- calls: rt.manager.getActiveCalls().map(toVoiceCallStatus)
1209
- });
1210
- return;
1211
- }
1212
- const call = await rt.manager.getCallFromMemoryOrStore(raw);
1213
- if (!call) {
1214
- respond(true, { found: false });
1215
- return;
1216
- }
1217
- respond(true, {
1218
- found: true,
1219
- call: toVoiceCallStatus(call)
1220
- });
1221
- } catch (err) {
1222
- sendError(respond, err);
1223
- }
934
+ registerGatewayCommand("voicecall.continue", ({ params }) => commands.continueCall(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.message)), VOICE_CALL_WRITE_METHOD_SCOPE);
935
+ registerGatewayCommand("voicecall.continue.start", async ({ params }) => continueOperationStore.start(await commands.prepareContinue(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.message))), VOICE_CALL_WRITE_METHOD_SCOPE);
936
+ registerGatewayCommand("voicecall.continue.result", ({ params }) => {
937
+ const operationId = normalizeOptionalString(params?.operationId);
938
+ if (!operationId) throw new VoiceCallCommandInputError("operationId required");
939
+ const operation = continueOperationStore.read(operationId);
940
+ if (!operation.ok) throw new VoiceCallCommandInputError(operation.error);
941
+ return operation.payload;
1224
942
  }, VOICE_CALL_READ_METHOD_SCOPE);
1225
- api.registerGatewayMethod("voicecall.start", async ({ params, client, respond }) => {
1226
- try {
1227
- const to = normalizeOptionalString(params?.to) ?? "";
1228
- const message = normalizeOptionalString(params?.message) ?? "";
1229
- const dtmfSequence = normalizeOptionalString(params?.dtmfSequence);
1230
- const sessionKey = normalizeOptionalString(params?.sessionKey);
1231
- const requesterSessionKey = normalizeOptionalString(params?.requesterSessionKey);
1232
- const requestedAgentId = normalizeOptionalString(params?.agentId);
1233
- const normalizedAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : void 0;
1234
- const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
1235
- if (requestedAgentId && (!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())) {
1236
- respondError(respond, "agentId requires a trusted plugin caller and a valid agent id", ErrorCodes.INVALID_REQUEST);
1237
- return;
1238
- }
1239
- if (!to) {
1240
- respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
1241
- return;
1242
- }
1243
- const mode = params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0;
1244
- const rt = await ensureRuntime();
1245
- await initiateCallAndRespond({
1246
- rt,
1247
- respond,
1248
- to,
1249
- message: message || void 0,
1250
- mode,
1251
- dtmfSequence,
1252
- sessionKey,
1253
- ...requesterSessionKey ? { requesterSessionKey } : {},
1254
- ...normalizedAgentId ? { agentId: normalizedAgentId } : {}
1255
- });
1256
- } catch (err) {
1257
- sendError(respond, err);
1258
- }
943
+ registerGatewayCommand("voicecall.speak", ({ params }) => commands.speak({
944
+ callId: normalizeOptionalString(params?.callId),
945
+ message: normalizeOptionalString(params?.message),
946
+ allowTwimlFallback: params?.allowTwimlFallback !== false
947
+ }), VOICE_CALL_WRITE_METHOD_SCOPE);
948
+ registerGatewayCommand("voicecall.dtmf", ({ params }) => commands.sendDtmf(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.digits)), VOICE_CALL_WRITE_METHOD_SCOPE);
949
+ registerGatewayCommand("voicecall.end", ({ params }) => commands.endCall(normalizeOptionalString(params?.callId)), VOICE_CALL_WRITE_METHOD_SCOPE);
950
+ registerGatewayCommand("voicecall.status", ({ params }) => commands.status(normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid)), VOICE_CALL_READ_METHOD_SCOPE);
951
+ registerGatewayCommand("voicecall.start", async ({ params, client }) => {
952
+ const to = normalizeOptionalString(params?.to);
953
+ const requestedAgentId = normalizeOptionalString(params?.agentId);
954
+ const normalizedAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : void 0;
955
+ const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
956
+ if (requestedAgentId && (!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())) throw new VoiceCallCommandInputError("agentId requires a trusted plugin caller and a valid agent id");
957
+ if (!to) throw new VoiceCallCommandInputError("to required");
958
+ return await commands.initiate({
959
+ to,
960
+ message: normalizeOptionalString(params?.message),
961
+ mode: params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0,
962
+ dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
963
+ sessionKey: normalizeOptionalString(params?.sessionKey),
964
+ requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
965
+ agentId: normalizedAgentId
966
+ });
1259
967
  }, VOICE_CALL_WRITE_METHOD_SCOPE);
1260
968
  api.registerTool((toolContext) => ({
1261
969
  name: "voice_call",
@@ -1263,94 +971,52 @@ var voice_call_default = definePluginEntry({
1263
971
  description: "Make phone calls and have voice conversations via the voice-call plugin.",
1264
972
  parameters: VoiceCallToolSchema,
1265
973
  async execute(_toolCallId, params) {
1266
- const rawParams = asParamRecord(params);
1267
- const agentId = normalizeOptionalString(toolContext.agentId);
974
+ const rawParams = asNonArrayRecord(params);
975
+ const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
976
+ const contextAgentId = normalizeOptionalString(toolContext.agentId) ?? parseAgentSessionKey(requesterSessionKey)?.agentId;
977
+ const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : void 0;
1268
978
  try {
1269
- const rt = await ensureRuntime();
979
+ await ensureRuntime();
1270
980
  if (typeof rawParams.action === "string") switch (rawParams.action) {
1271
981
  case "initiate_call": {
1272
- const message = normalizeOptionalString(rawParams.message) ?? "";
1273
- if (!message) throw new Error("message required");
1274
- const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
1275
- if (!to) throw new Error("to required");
1276
- const result = await rt.manager.initiateCall(to, void 0, {
982
+ const message = normalizeOptionalString(rawParams.message);
983
+ if (!message) throw new VoiceCallCommandInputError("message required");
984
+ return jsonResult(await commands.initiate({
985
+ to: normalizeOptionalString(rawParams.to),
1277
986
  message,
1278
987
  dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
1279
988
  mode: rawParams.mode === "notify" || rawParams.mode === "conversation" ? rawParams.mode : void 0,
1280
- ...agentId ? { agentId } : {}
1281
- });
1282
- if (!result.success) throw new Error(result.error || "initiate failed");
1283
- return jsonResult({
1284
- callId: result.callId,
1285
- initiated: true
1286
- });
1287
- }
1288
- case "continue_call": {
1289
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1290
- const message = normalizeOptionalString(rawParams.message) ?? "";
1291
- if (!callId || !message) throw new Error("callId and message required");
1292
- const result = await rt.manager.continueCall(callId, message);
1293
- if (!result.success) throw new Error(result.error || "continue failed");
1294
- return jsonResult({
1295
- success: true,
1296
- transcript: result.transcript
1297
- });
1298
- }
1299
- case "speak_to_user": {
1300
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1301
- const message = normalizeOptionalString(rawParams.message) ?? "";
1302
- if (!callId || !message) throw new Error("callId and message required");
1303
- const result = await rt.manager.speak(callId, message);
1304
- if (!result.success) throw new Error(result.error || "speak failed");
1305
- return jsonResult({ success: true });
1306
- }
1307
- case "send_dtmf": {
1308
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1309
- const digits = normalizeOptionalString(rawParams.digits) ?? "";
1310
- if (!callId || !digits) throw new Error("callId and digits required");
1311
- const result = await rt.manager.sendDtmf(callId, digits);
1312
- if (!result.success) throw new Error(result.error || "dtmf failed");
1313
- return jsonResult({ success: true });
1314
- }
1315
- case "end_call": {
1316
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1317
- if (!callId) throw new Error("callId required");
1318
- const result = await rt.manager.endCall(callId);
1319
- if (!result.success) throw new Error(result.error || "end failed");
1320
- return jsonResult({ success: true });
989
+ sessionKey: normalizeOptionalString(rawParams.sessionKey),
990
+ agentId,
991
+ requesterSessionKey
992
+ }));
1321
993
  }
994
+ case "continue_call": return jsonResult(await commands.continueCall(normalizeOptionalString(rawParams.callId), normalizeOptionalString(rawParams.message)));
995
+ case "speak_to_user": return jsonResult(await commands.speak({
996
+ callId: normalizeOptionalString(rawParams.callId),
997
+ message: normalizeOptionalString(rawParams.message)
998
+ }));
999
+ case "send_dtmf": return jsonResult(await commands.sendDtmf(normalizeOptionalString(rawParams.callId), normalizeOptionalString(rawParams.digits)));
1000
+ case "end_call": return jsonResult(await commands.endCall(normalizeOptionalString(rawParams.callId)));
1322
1001
  case "get_status": {
1323
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1324
- if (!callId) throw new Error("callId required");
1325
- const call = await rt.manager.getCallFromMemoryOrStore(callId);
1326
- return jsonResult(call ? {
1327
- found: true,
1328
- call: toVoiceCallStatus(call)
1329
- } : { found: false });
1002
+ const callId = normalizeOptionalString(rawParams.callId);
1003
+ if (!callId) throw new VoiceCallCommandInputError("callId required");
1004
+ return jsonResult(await commands.status(callId));
1330
1005
  }
1331
1006
  }
1332
1007
  if ((rawParams.mode ?? "call") === "status") {
1333
1008
  const sid = normalizeOptionalString(rawParams.sid) ?? "";
1334
1009
  if (!sid) throw new Error("sid required for status");
1335
- const call = await rt.manager.getCallFromMemoryOrStore(sid);
1336
- return jsonResult(call ? {
1337
- found: true,
1338
- call: toVoiceCallStatus(call)
1339
- } : { found: false });
1010
+ return jsonResult(await commands.status(sid));
1340
1011
  }
1341
- const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
1342
- if (!to) throw new Error("to required for call");
1343
- const result = await rt.manager.initiateCall(to, normalizeOptionalString(rawParams.sessionKey), {
1012
+ return jsonResult(await commands.initiate({
1013
+ to: normalizeOptionalString(rawParams.to),
1344
1014
  dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
1345
1015
  message: normalizeOptionalString(rawParams.message),
1346
- ...agentId ? { agentId } : {},
1347
- ...normalizeOptionalString(rawParams.requesterSessionKey) ? { requesterSessionKey: normalizeOptionalString(rawParams.requesterSessionKey) } : {}
1348
- });
1349
- if (!result.success) throw new Error(result.error || "initiate failed");
1350
- return jsonResult({
1351
- callId: result.callId,
1352
- initiated: true
1353
- });
1016
+ sessionKey: normalizeOptionalString(rawParams.sessionKey),
1017
+ agentId,
1018
+ requesterSessionKey
1019
+ }, "to required for call"));
1354
1020
  } catch (err) {
1355
1021
  return jsonResult({ error: formatErrorMessage(err) });
1356
1022
  }
@@ -1370,33 +1036,46 @@ var voice_call_default = definePluginEntry({
1370
1036
  id: "voicecall",
1371
1037
  start: () => {
1372
1038
  if (isCliOnlyProcess()) return;
1039
+ try {
1040
+ activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
1041
+ } catch (err) {
1042
+ if (err instanceof VoiceCallRuntimeLifecycleError) return;
1043
+ throw err;
1044
+ }
1373
1045
  if (!config.enabled) return;
1374
1046
  if (!validation.valid) {
1375
1047
  api.logger.warn(`[voice-call] Runtime not started; setup incomplete: ${validation.errors.join("; ")}`);
1376
1048
  return;
1377
1049
  }
1378
1050
  ensureRuntime().catch((err) => {
1051
+ const staleGeneration = runtimeGeneration.retired || runtimeGeneration.epoch < runtimeCoordinator.registeredEpoch;
1052
+ if (err instanceof VoiceCallRuntimeLifecycleError && staleGeneration) return;
1379
1053
  api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
1380
1054
  });
1381
1055
  },
1382
1056
  stop: async () => {
1383
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
1384
- await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
1385
- return;
1386
- }
1387
- const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
1388
- const runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
1389
- if (!runtime && !runtimePromise) return;
1390
- runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
1391
- runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
1392
- const stopPromise = (async () => {
1393
- await (runtime ?? await runtimePromise).stop();
1394
- })();
1395
- runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = stopPromise;
1057
+ if (runtimeGeneration.stopPromise) return await runtimeGeneration.stopPromise;
1058
+ runtimeGeneration.retired = true;
1059
+ const ownedSlot = runtimeCoordinator.slot?.owner === runtimeGeneration ? runtimeCoordinator.slot : void 0;
1060
+ let stoppingSlot;
1061
+ const stopPromise = ownedSlot?.state === "stopping" ? ownedSlot.promise : Promise.resolve().then(async () => {
1062
+ if (!ownedSlot) return;
1063
+ await (ownedSlot.state === "running" ? ownedSlot.runtime : await ownedSlot.promise).stop();
1064
+ });
1065
+ runtimeGeneration.stopPromise = stopPromise;
1066
+ if (ownedSlot && ownedSlot.state !== "stopping") {
1067
+ stoppingSlot = {
1068
+ state: "stopping",
1069
+ owner: runtimeGeneration,
1070
+ promise: stopPromise
1071
+ };
1072
+ if (runtimeCoordinator.slot === ownedSlot) runtimeCoordinator.slot = stoppingSlot;
1073
+ } else if (ownedSlot) stoppingSlot = ownedSlot;
1396
1074
  try {
1397
1075
  await stopPromise;
1398
1076
  } finally {
1399
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] === stopPromise) runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = null;
1077
+ if (stoppingSlot && runtimeCoordinator.slot === stoppingSlot) runtimeCoordinator.slot = void 0;
1078
+ if (runtimeCoordinator.current === runtimeGeneration) runtimeCoordinator.current = void 0;
1400
1079
  }
1401
1080
  }
1402
1081
  });