@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.
@@ -1,10 +1,9 @@
1
1
  import { TtsConfigSchema, isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { t as TWILIO_REGIONS } from "./twilio-region-4fkgz3UG.js";
4
- import { _ as TerminalStates, d as loadActiveCallsFromStore, g as setVoiceCallStateRuntime, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-nYL-yM0S.js";
4
+ import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-51_oC_xn.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
- import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
8
7
  import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
9
8
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
10
9
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
@@ -21,6 +20,7 @@ import fs from "node:fs";
21
20
  import crypto from "node:crypto";
22
21
  import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
23
22
  import path from "node:path";
23
+ import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
24
24
  import os from "node:os";
25
25
  import { root } from "openclaw/plugin-sdk/security-runtime";
26
26
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
@@ -1207,7 +1207,8 @@ async function continueCall(ctx, callId, prompt) {
1207
1207
  const turnStartedAt = Date.now();
1208
1208
  const turnToken = provider.name === "twilio" ? crypto.randomUUID() : void 0;
1209
1209
  try {
1210
- await speak(ctx, callId, prompt);
1210
+ const speakResult = await speak(ctx, callId, prompt);
1211
+ if (!speakResult.success) return speakResult;
1211
1212
  transitionState(call, "listening");
1212
1213
  persistCallRecord(ctx.storePath, call);
1213
1214
  const listenStartedAt = Date.now();
@@ -1329,9 +1330,9 @@ function createWebhookCall(params) {
1329
1330
  ...effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}
1330
1331
  }
1331
1332
  };
1333
+ persistCallRecord(params.ctx.storePath, callRecord);
1332
1334
  params.ctx.activeCalls.set(callId, callRecord);
1333
1335
  params.ctx.providerCallIdMap.set(params.providerCallId, callId);
1334
- persistCallRecord(params.ctx.storePath, callRecord);
1335
1336
  log.info(`Created ${params.direction} call record: ${callId} caller=${redactIdentifier(params.from)}`);
1336
1337
  return callRecord;
1337
1338
  }
@@ -1372,9 +1373,7 @@ function processEvent(ctx, event) {
1372
1373
  log.warn(`Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, caller=${redactIdentifier(event.from)}); call will time out on provider side.`);
1373
1374
  return { kind: "ignored" };
1374
1375
  }
1375
- ctx.processedEventIds.add(dedupeKey);
1376
1376
  if (ctx.rejectedProviderCallIds.has(pid)) return { kind: "ignored" };
1377
- ctx.rejectedProviderCallIds.add(pid);
1378
1377
  const callId = event.callId ?? pid;
1379
1378
  persistRejectedInboundCall({
1380
1379
  ctx,
@@ -1382,13 +1381,16 @@ function processEvent(ctx, event) {
1382
1381
  dedupeKey,
1383
1382
  providerCallId: pid
1384
1383
  });
1384
+ const rejectionReservation = reserveRejectedProviderCall(ctx.rejectedProviderCallIds, pid);
1385
+ if (rejectionReservation === void 0) return { kind: "ignored" };
1386
+ rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1385
1387
  log.info(`Rejecting inbound call by policy: ${pid}`);
1386
1388
  ctx.provider.hangupCall({
1387
1389
  callId,
1388
1390
  providerCallId: pid,
1389
1391
  reason: "hangup-bot"
1390
1392
  }).catch((err) => {
1391
- ctx.rejectedProviderCallIds.delete(pid);
1393
+ releaseRejectedProviderCall(ctx.rejectedProviderCallIds, pid, rejectionReservation);
1392
1394
  const message = formatErrorMessage(err);
1393
1395
  log.warn(`Failed to reject inbound call ${pid}: ${message}`);
1394
1396
  });
@@ -1403,126 +1405,162 @@ function processEvent(ctx, event) {
1403
1405
  });
1404
1406
  event.callId = call.callId;
1405
1407
  }
1406
- if (!call) return { kind: "ignored" };
1407
- if (event.providerCallId && event.providerCallId !== call.providerCallId) {
1408
- const previousProviderCallId = call.providerCallId;
1409
- call.providerCallId = event.providerCallId;
1410
- ctx.providerCallIdMap.set(event.providerCallId, call.callId);
1411
- if (previousProviderCallId) {
1412
- if (ctx.providerCallIdMap.get(previousProviderCallId) === call.callId) ctx.providerCallIdMap.delete(previousProviderCallId);
1413
- }
1414
- }
1415
- if (!(event.type === "call.error" && event.retryable)) {
1416
- ctx.processedEventIds.add(dedupeKey);
1417
- call.processedEventIds.push(dedupeKey);
1418
- }
1408
+ if (!call) return {
1409
+ kind: "ignored",
1410
+ replayable: true
1411
+ };
1412
+ const activeCall = call;
1413
+ const previousCall = {
1414
+ state: activeCall.state,
1415
+ providerCallId: activeCall.providerCallId,
1416
+ answeredAt: activeCall.answeredAt,
1417
+ endedAt: activeCall.endedAt,
1418
+ endReason: activeCall.endReason,
1419
+ transcriptLength: activeCall.transcript.length,
1420
+ processedEventIds: [...activeCall.processedEventIds]
1421
+ };
1422
+ const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
1423
+ const effects = [];
1419
1424
  let result = { kind: "processed" };
1420
- switch (event.type) {
1421
- case "call.initiated":
1422
- transitionState(call, "initiated");
1423
- if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
1424
- const inboundStreamSession = ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
1425
- providerName: "telnyx",
1426
- callId: call.callId,
1427
- from: call.from,
1428
- to: call.to,
1429
- direction: "inbound"
1430
- }) : void 0;
1431
- ctx.provider.answerCall({
1432
- callId: call.callId,
1433
- providerCallId: call.providerCallId,
1434
- ...inboundStreamSession ? {
1435
- streamUrl: inboundStreamSession.streamUrl,
1436
- streamAuthToken: inboundStreamSession.token
1437
- } : {}
1438
- }).catch((err) => {
1439
- const message = formatErrorMessage(err);
1440
- log.warn(`Failed to answer inbound call ${call.providerCallId}: ${message}`);
1441
- });
1425
+ const startDurationTimer = () => {
1426
+ startMaxDurationTimer({
1427
+ ctx,
1428
+ callId: activeCall.callId,
1429
+ onTimeout: async (callId) => {
1430
+ await endCall(ctx, callId, { reason: "timeout" });
1442
1431
  }
1443
- break;
1444
- case "call.ringing":
1445
- transitionState(call, "ringing");
1446
- break;
1447
- case "call.answered":
1448
- call.answeredAt = event.timestamp;
1449
- transitionState(call, "answered");
1450
- startMaxDurationTimer({
1451
- ctx,
1452
- callId: call.callId,
1453
- onTimeout: async (callId) => {
1454
- await endCall(ctx, callId, { reason: "timeout" });
1455
- }
1456
- });
1457
- ctx.onCallAnswered?.(call);
1458
- break;
1459
- case "call.active":
1460
- transitionState(call, "active");
1461
- break;
1462
- case "call.speaking":
1463
- case "call.assistant-speech":
1464
- ensureMaxDurationTimerForLiveCall({
1465
- ctx,
1466
- call,
1467
- liveAt: event.timestamp,
1468
- onTimeout: async (callId) => {
1469
- await endCall(ctx, callId, { reason: "timeout" });
1470
- }
1471
- });
1472
- transitionState(call, "speaking");
1473
- if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(call, "bot", event.transcript);
1474
- break;
1475
- case "call.speech":
1476
- if (event.isFinal) {
1477
- const hadWaiter = ctx.transcriptWaiters.has(call.callId);
1478
- const resolved = resolveTranscriptWaiter(ctx, call.callId, event.transcript, event.turnToken);
1479
- if (hadWaiter && !resolved) {
1480
- log.warn(`Ignoring speech event with mismatched turn token for ${call.callId}`);
1481
- result = { kind: "ignored" };
1482
- break;
1483
- }
1484
- addTranscriptEntry(call, "user", event.transcript);
1485
- result = {
1486
- kind: "final-speech",
1487
- call,
1488
- transcript: event.transcript,
1489
- waiterResolved: resolved
1490
- };
1432
+ });
1433
+ };
1434
+ const prepareLiveDurationTimer = () => {
1435
+ if (!activeCall.answeredAt) {
1436
+ activeCall.answeredAt = event.timestamp;
1437
+ effects.push(startDurationTimer);
1438
+ }
1439
+ };
1440
+ const publishProviderCallId = (terminal = false) => {
1441
+ if (!event.providerCallId || event.providerCallId === previousCall.providerCallId) return;
1442
+ if (!terminal) ctx.providerCallIdMap.set(event.providerCallId, activeCall.callId);
1443
+ if (previousCall.providerCallId) {
1444
+ if (ctx.providerCallIdMap.get(previousCall.providerCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousCall.providerCallId);
1445
+ }
1446
+ };
1447
+ try {
1448
+ if (event.providerCallId && event.providerCallId !== activeCall.providerCallId) activeCall.providerCallId = event.providerCallId;
1449
+ if (shouldCommitReplayKey) appendCallReplayKey(activeCall.processedEventIds, dedupeKey);
1450
+ switch (event.type) {
1451
+ case "call.initiated": {
1452
+ transitionState(activeCall, "initiated");
1453
+ const inboundProvider = ctx.provider;
1454
+ const inboundProviderCallId = activeCall.providerCallId;
1455
+ const answerInboundCall = inboundProvider?.answerCall?.bind(inboundProvider);
1456
+ if (activeCall.direction === "inbound" && inboundProviderCallId && answerInboundCall) effects.push(() => {
1457
+ const inboundStreamSession = ctx.config.realtime?.enabled && inboundProvider?.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
1458
+ providerName: "telnyx",
1459
+ callId: activeCall.callId,
1460
+ from: activeCall.from,
1461
+ to: activeCall.to,
1462
+ direction: "inbound"
1463
+ }) : void 0;
1464
+ answerInboundCall({
1465
+ callId: activeCall.callId,
1466
+ providerCallId: inboundProviderCallId,
1467
+ ...inboundStreamSession ? {
1468
+ streamUrl: inboundStreamSession.streamUrl,
1469
+ streamAuthToken: inboundStreamSession.token
1470
+ } : {}
1471
+ }).catch((err) => {
1472
+ const message = formatErrorMessage(err);
1473
+ log.warn(`Failed to answer inbound call ${activeCall.providerCallId}: ${message}`);
1474
+ });
1475
+ });
1476
+ break;
1491
1477
  }
1492
- ensureMaxDurationTimerForLiveCall({
1493
- ctx,
1494
- call,
1495
- liveAt: event.timestamp,
1496
- onTimeout: async (callId) => {
1497
- await endCall(ctx, callId, { reason: "timeout" });
1478
+ case "call.ringing":
1479
+ transitionState(activeCall, "ringing");
1480
+ break;
1481
+ case "call.answered":
1482
+ activeCall.answeredAt = event.timestamp;
1483
+ transitionState(activeCall, "answered");
1484
+ effects.push(startDurationTimer, () => ctx.onCallAnswered?.(activeCall));
1485
+ break;
1486
+ case "call.active":
1487
+ transitionState(activeCall, "active");
1488
+ break;
1489
+ case "call.speaking":
1490
+ case "call.assistant-speech":
1491
+ prepareLiveDurationTimer();
1492
+ transitionState(activeCall, "speaking");
1493
+ if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(activeCall, "bot", event.transcript);
1494
+ break;
1495
+ case "call.speech":
1496
+ if (event.isFinal) {
1497
+ const waiter = ctx.transcriptWaiters.get(activeCall.callId);
1498
+ if (waiter?.turnToken && waiter.turnToken !== event.turnToken) {
1499
+ log.warn(`Ignoring speech event with mismatched turn token for ${activeCall.callId}`);
1500
+ result = { kind: "ignored" };
1501
+ break;
1502
+ }
1503
+ addTranscriptEntry(activeCall, "user", event.transcript);
1504
+ result = {
1505
+ kind: "final-speech",
1506
+ call: activeCall,
1507
+ transcript: event.transcript,
1508
+ waiterResolved: Boolean(waiter)
1509
+ };
1510
+ if (waiter) effects.push(() => {
1511
+ resolveTranscriptWaiter(ctx, activeCall.callId, event.transcript, event.turnToken);
1512
+ });
1498
1513
  }
1499
- });
1500
- transitionState(call, "listening");
1501
- break;
1502
- case "call.silence":
1503
- case "call.dtmf": break;
1504
- case "call.ended":
1505
- finalizeCall({
1506
- ctx,
1507
- call,
1508
- endReason: event.reason,
1509
- endedAt: event.timestamp
1510
- });
1511
- return { kind: "processed" };
1512
- case "call.error":
1513
- if (!event.retryable) {
1514
+ prepareLiveDurationTimer();
1515
+ transitionState(activeCall, "listening");
1516
+ break;
1517
+ case "call.silence":
1518
+ case "call.dtmf": break;
1519
+ case "call.ended":
1514
1520
  finalizeCall({
1515
1521
  ctx,
1516
- call,
1517
- endReason: "error",
1518
- endedAt: event.timestamp,
1519
- transcriptRejectReason: `Call error: ${event.error}`
1522
+ call: activeCall,
1523
+ endReason: event.reason,
1524
+ endedAt: event.timestamp
1520
1525
  });
1526
+ publishProviderCallId(true);
1527
+ rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1521
1528
  return { kind: "processed" };
1522
- }
1523
- break;
1529
+ case "call.error":
1530
+ if (!event.retryable) {
1531
+ finalizeCall({
1532
+ ctx,
1533
+ call: activeCall,
1534
+ endReason: "error",
1535
+ endedAt: event.timestamp,
1536
+ transcriptRejectReason: `Call error: ${event.error}`
1537
+ });
1538
+ publishProviderCallId(true);
1539
+ rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1540
+ return { kind: "processed" };
1541
+ }
1542
+ result = {
1543
+ kind: "processed",
1544
+ replayable: true
1545
+ };
1546
+ break;
1547
+ }
1548
+ persistCallRecord(ctx.storePath, activeCall);
1549
+ publishProviderCallId();
1550
+ if (shouldCommitReplayKey) rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1551
+ } catch (err) {
1552
+ Object.assign(activeCall, {
1553
+ state: previousCall.state,
1554
+ providerCallId: previousCall.providerCallId,
1555
+ answeredAt: previousCall.answeredAt,
1556
+ endedAt: previousCall.endedAt,
1557
+ endReason: previousCall.endReason
1558
+ });
1559
+ activeCall.transcript.length = previousCall.transcriptLength;
1560
+ activeCall.processedEventIds.splice(0, activeCall.processedEventIds.length, ...previousCall.processedEventIds);
1561
+ throw err;
1524
1562
  }
1525
- persistCallRecord(ctx.storePath, call);
1563
+ for (const effect of effects) effect();
1526
1564
  return result;
1527
1565
  }
1528
1566
  //#endregion
@@ -1564,7 +1602,7 @@ var CallManager = class {
1564
1602
  this.activeCalls = /* @__PURE__ */ new Map();
1565
1603
  this.providerCallIdMap = /* @__PURE__ */ new Map();
1566
1604
  this.processedEventIds = /* @__PURE__ */ new Set();
1567
- this.rejectedProviderCallIds = /* @__PURE__ */ new Set();
1605
+ this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
1568
1606
  this.provider = null;
1569
1607
  this.webhookUrl = null;
1570
1608
  this.activeTurnCalls = /* @__PURE__ */ new Set();
@@ -1585,7 +1623,7 @@ var CallManager = class {
1585
1623
  fs.mkdirSync(this.storePath, { recursive: true });
1586
1624
  const persisted = loadActiveCallsFromStore(this.storePath);
1587
1625
  this.processedEventIds = persisted.processedEventIds;
1588
- this.rejectedProviderCallIds = persisted.rejectedProviderCallIds;
1626
+ this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
1589
1627
  const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls);
1590
1628
  this.activeCalls = verified;
1591
1629
  this.providerCallIdMap = /* @__PURE__ */ new Map();
@@ -2381,6 +2419,7 @@ const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
2381
2419
  const DEFAULT_MAX_CONNECTIONS = 128;
2382
2420
  const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
2383
2421
  const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
2422
+ const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
2384
2423
  const CLOSE_REASON_LOG_MAX_CHARS = 120;
2385
2424
  function sanitizeLogText(value, maxChars) {
2386
2425
  const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
@@ -2406,6 +2445,8 @@ function parseTwilioMediaMessage(data) {
2406
2445
  var MediaStreamHandler = class {
2407
2446
  constructor(config) {
2408
2447
  this.wss = null;
2448
+ this.closePromise = null;
2449
+ this.closing = false;
2409
2450
  this.sessions = /* @__PURE__ */ new Map();
2410
2451
  this.pendingConnections = /* @__PURE__ */ new Map();
2411
2452
  this.pendingByIp = /* @__PURE__ */ new Map();
@@ -2423,6 +2464,10 @@ var MediaStreamHandler = class {
2423
2464
  * Handle WebSocket upgrade for media stream connections.
2424
2465
  */
2425
2466
  handleUpgrade(request, socket, head) {
2467
+ if (this.closing) {
2468
+ this.rejectUpgrade(socket, 503, "Media stream handler is shutting down");
2469
+ return;
2470
+ }
2426
2471
  if (!this.wss) {
2427
2472
  this.wss = new WebSocketServer({
2428
2473
  noServer: true,
@@ -2464,6 +2509,23 @@ var MediaStreamHandler = class {
2464
2509
  throw error;
2465
2510
  }
2466
2511
  }
2512
+ close(shutdownBarrier = Promise.resolve()) {
2513
+ if (this.closePromise) return this.closePromise;
2514
+ this.closing = true;
2515
+ const wss = this.wss;
2516
+ this.wss = null;
2517
+ this.closePromise = (async () => {
2518
+ if (wss) await new Promise((resolve) => {
2519
+ wss.close(() => resolve());
2520
+ for (const ws of wss.clients) ws.terminate();
2521
+ });
2522
+ await shutdownBarrier;
2523
+ })().finally(() => {
2524
+ this.closing = false;
2525
+ this.closePromise = null;
2526
+ });
2527
+ return this.closePromise;
2528
+ }
2467
2529
  /**
2468
2530
  * Handle new WebSocket connection from Twilio.
2469
2531
  */
@@ -2483,6 +2545,11 @@ var MediaStreamHandler = class {
2483
2545
  console.log("[MediaStream] Twilio connected");
2484
2546
  break;
2485
2547
  case "start":
2548
+ if (session) {
2549
+ console.warn("[MediaStream] Rejecting duplicate start frame for active connection");
2550
+ ws.close(1008, "Duplicate start");
2551
+ break;
2552
+ }
2486
2553
  session = this.handleStart(ws, message, streamToken);
2487
2554
  if (session) this.clearPendingConnection(ws);
2488
2555
  break;
@@ -2852,6 +2919,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2852
2919
  */
2853
2920
  async queueTts(streamSid, playFn) {
2854
2921
  const queue = this.getTtsQueue(streamSid);
2922
+ if (queue.length >= MAX_PENDING_TTS_OPERATIONS_PER_STREAM) throw new Error(`Telephony TTS queue is full for stream; maxPending=${MAX_PENDING_TTS_OPERATIONS_PER_STREAM}`);
2855
2923
  let resolveEntry;
2856
2924
  let rejectEntry;
2857
2925
  const promise = new Promise((resolve, reject) => {
@@ -2871,8 +2939,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2871
2939
  * Clear TTS queue and interrupt current playback (barge-in).
2872
2940
  */
2873
2941
  clearTtsQueue(streamSid, _reason = "unspecified") {
2874
- const queue = this.getTtsQueue(streamSid);
2875
- this.resolveQueuedTtsEntries(queue);
2942
+ const queue = this.ttsQueues.get(streamSid);
2943
+ if (queue) this.resolveQueuedTtsEntries(queue);
2876
2944
  this.ttsActiveControllers.get(streamSid)?.abort();
2877
2945
  const session = this.sessions.get(streamSid);
2878
2946
  if (session?.talk.activeTurnId) {
@@ -2901,8 +2969,9 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2901
2969
  while (true) {
2902
2970
  const queue = this.ttsQueues.get(streamSid);
2903
2971
  if (!queue || queue.length === 0) {
2904
- this.ttsPlaying.set(streamSid, false);
2972
+ this.ttsPlaying.delete(streamSid);
2905
2973
  this.ttsActiveControllers.delete(streamSid);
2974
+ this.ttsQueues.delete(streamSid);
2906
2975
  return;
2907
2976
  }
2908
2977
  const entry = queue.shift();
@@ -3071,7 +3140,7 @@ const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
3071
3140
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
3072
3141
  const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
3073
3142
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
3074
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D-7ApVfT.js"));
3143
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D-hkzo5Y.js"));
3075
3144
  function appendRecentTalkEventMetadata(call, event) {
3076
3145
  const metadata = call.metadata ?? {};
3077
3146
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -3147,6 +3216,7 @@ var VoiceCallWebhookServer = class {
3147
3216
  this.server = null;
3148
3217
  this.listeningUrl = null;
3149
3218
  this.startPromise = null;
3219
+ this.stopPromise = null;
3150
3220
  this.stopStaleCallReaper = null;
3151
3221
  this.webhookInFlightLimiter = createWebhookInFlightLimiter();
3152
3222
  this.mediaStreamHandler = null;
@@ -3348,6 +3418,7 @@ var VoiceCallWebhookServer = class {
3348
3418
  * Idempotent: returns immediately if the server is already listening.
3349
3419
  */
3350
3420
  async start() {
3421
+ if (this.stopPromise) await this.stopPromise;
3351
3422
  const { port, bind, path: webhookPath } = this.config.serve;
3352
3423
  const streamPath = this.config.streaming.streamPath;
3353
3424
  if (this.server?.listening) return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
@@ -3397,26 +3468,46 @@ var VoiceCallWebhookServer = class {
3397
3468
  /**
3398
3469
  * Stop the webhook server.
3399
3470
  */
3400
- async stop() {
3471
+ stop() {
3472
+ if (this.stopPromise) return this.stopPromise;
3473
+ const server = this.server;
3474
+ const serverClosePromise = new Promise((resolve, reject) => {
3475
+ if (!server) {
3476
+ resolve();
3477
+ return;
3478
+ }
3479
+ server.close((error) => {
3480
+ if (error) {
3481
+ reject(error);
3482
+ return;
3483
+ }
3484
+ resolve();
3485
+ });
3486
+ });
3487
+ this.startPromise = null;
3401
3488
  for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
3402
3489
  this.pendingDisconnectHangups.clear();
3403
- this.webhookInFlightLimiter.clear();
3404
- this.startPromise = null;
3405
3490
  if (this.stopStaleCallReaper) {
3406
3491
  this.stopStaleCallReaper();
3407
3492
  this.stopStaleCallReaper = null;
3408
3493
  }
3409
- return new Promise((resolve) => {
3410
- if (this.server) this.server.close(() => {
3411
- this.server = null;
3412
- this.listeningUrl = null;
3413
- resolve();
3414
- });
3415
- else {
3416
- this.listeningUrl = null;
3417
- resolve();
3418
- }
3494
+ this.webhookInFlightLimiter.clear();
3495
+ this.stopPromise = (async () => {
3496
+ const results = await Promise.allSettled([
3497
+ serverClosePromise,
3498
+ this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
3499
+ this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
3500
+ ]);
3501
+ for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
3502
+ this.pendingDisconnectHangups.clear();
3503
+ if (this.server === server) this.server = null;
3504
+ this.listeningUrl = null;
3505
+ const failure = results.find((result) => result.status === "rejected");
3506
+ if (failure) throw failure.reason;
3507
+ })().finally(() => {
3508
+ this.stopPromise = null;
3419
3509
  });
3510
+ return this.stopPromise;
3420
3511
  }
3421
3512
  resolveListeningUrl(bind, webhookPath) {
3422
3513
  const address = this.server?.address();
@@ -3521,9 +3612,9 @@ var VoiceCallWebhookServer = class {
3521
3612
  const isReplay = Boolean(verification.isReplay);
3522
3613
  if (isReplay) {
3523
3614
  this.logger.warn("Replay detected; skipping event side effects");
3524
- if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
3525
3615
  const cachedResponse = await this.getCachedReplayResponse(verification.verifiedRequestKey);
3526
3616
  if (cachedResponse) return cachedResponse;
3617
+ if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
3527
3618
  }
3528
3619
  const buildResponse = async () => {
3529
3620
  const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
@@ -3547,12 +3638,11 @@ var VoiceCallWebhookServer = class {
3547
3638
  return this.realtimeHandler.buildTwiMLPayload(req, realtimeParams);
3548
3639
  }
3549
3640
  const parsed = this.provider.parseWebhookEvent(ctx, { verifiedRequestKey: verification.verifiedRequestKey });
3550
- if (!isReplay) this.processParsedEvents(parsed.events);
3641
+ if (!isReplay && this.processParsedEvents(parsed.events)) verification.releaseReplay?.();
3551
3642
  return normalizeWebhookResponse(parsed);
3552
3643
  };
3553
3644
  if (isReplay) return await buildResponse();
3554
- if (this.provider.name === "twilio") return await buildResponse();
3555
- return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse);
3645
+ return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse, verification.releaseReplay);
3556
3646
  } finally {
3557
3647
  this.webhookInFlightLimiter.release(inFlightKey);
3558
3648
  }
@@ -3578,21 +3668,28 @@ var VoiceCallWebhookServer = class {
3578
3668
  }
3579
3669
  return cloneWebhookResponsePayload(await entry.response);
3580
3670
  }
3581
- async cacheReplayResponse(key, buildResponse) {
3671
+ async cacheReplayResponse(key, buildResponse, releaseReplay) {
3582
3672
  const now = Date.now();
3583
3673
  const expiresAt = resolveExpiresAtMsFromDurationMs(WEBHOOK_REPLAY_RESPONSE_TTL_MS, { nowMs: now });
3584
3674
  this.replayResponseCacheCalls += 1;
3585
3675
  if (this.replayResponseCacheCalls % WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL === 0) this.pruneReplayResponses(now);
3586
- const response = buildResponse().then(cloneWebhookResponsePayload).catch((err) => {
3587
- this.replayResponses.delete(key);
3676
+ let cachedEntry;
3677
+ const ownerResponse = buildResponse().then(cloneWebhookResponsePayload).catch((err) => {
3678
+ if (cachedEntry && this.replayResponses.get(key) === cachedEntry) this.replayResponses.delete(key);
3679
+ releaseReplay?.();
3588
3680
  throw err;
3589
3681
  });
3590
- if (expiresAt !== void 0) this.replayResponses.set(key, {
3591
- expiresAt,
3592
- response
3593
- });
3682
+ const response = ownerResponse.then((payload) => this.provider.name === "twilio" ? buildTwilioReplayTwiML() : cloneWebhookResponsePayload(payload));
3683
+ response.catch(() => {});
3684
+ if (expiresAt !== void 0) {
3685
+ cachedEntry = {
3686
+ expiresAt,
3687
+ response
3688
+ };
3689
+ this.replayResponses.set(key, cachedEntry);
3690
+ }
3594
3691
  if (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) this.pruneReplayResponses(now);
3595
- return cloneWebhookResponsePayload(await response);
3692
+ return cloneWebhookResponsePayload(await ownerResponse);
3596
3693
  }
3597
3694
  verifyPreAuthWebhookHeaders(headers) {
3598
3695
  if (this.config.skipSignatureVerification) return { ok: true };
@@ -3656,20 +3753,25 @@ var VoiceCallWebhookServer = class {
3656
3753
  }
3657
3754
  }
3658
3755
  processParsedEvents(events) {
3756
+ let replayable = false;
3659
3757
  for (const event of events) try {
3660
- this.processEventWithAutoResponse(event);
3758
+ replayable = this.processEventWithAutoResponse(event) || replayable;
3661
3759
  } catch (err) {
3662
3760
  this.logger.error(`Error processing event ${event.type}: ${String(err)}`);
3761
+ throw err;
3663
3762
  }
3763
+ return replayable;
3664
3764
  }
3665
3765
  processEventWithAutoResponse(event) {
3666
3766
  const result = this.manager.processEvent(event);
3667
- if (result.kind !== "final-speech" || result.waiterResolved) return;
3767
+ if (result.kind !== "final-speech") return result.replayable === true;
3768
+ if (result.waiterResolved) return false;
3668
3769
  const callMode = result.call.metadata?.mode;
3669
- if (result.call.direction !== "inbound" && callMode !== "conversation") return;
3770
+ if (result.call.direction !== "inbound" && callMode !== "conversation") return false;
3670
3771
  this.handleInboundResponse(result.call.callId, result.transcript).catch((err) => {
3671
3772
  this.logger.warn(`Failed to auto-respond: ${String(err)}`);
3672
3773
  });
3774
+ return false;
3673
3775
  }
3674
3776
  writeWebhookResponse(res, payload) {
3675
3777
  res.statusCode = payload.statusCode;
@@ -3746,12 +3848,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3746
3848
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3747
3849
  "Be accurate, brief, and speakable."
3748
3850
  ].join(" ");
3749
- const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-Dx6cfLvV.js"));
3750
- const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-D_D8KysU.js"));
3751
- const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-vZ--Wqxv.js"));
3752
- const loadMockProvider = createLazyRuntimeModule(() => import("./mock-YsvBTX-7.js"));
3851
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-sIS_WgnY.js"));
3852
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-CbeVrVAl.js"));
3853
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-DbI17uAc.js"));
3854
+ const loadMockProvider = createLazyRuntimeModule(() => import("./mock-BCq0q36l.js"));
3753
3855
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
3754
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler--i_OERv6.js"));
3856
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-uXR0QgVa.js"));
3755
3857
  function resolveVoiceCallConsultSessionKey(call) {
3756
3858
  return resolveVoiceCallSessionKey({
3757
3859
  config: call.config,
@@ -3775,7 +3877,7 @@ function mapVoiceCallConsultTranscript(call, context) {
3775
3877
  }
3776
3878
  function createRuntimeResourceLifecycle(params) {
3777
3879
  let tunnelResult = null;
3778
- let stopped = false;
3880
+ let stopPromise = null;
3779
3881
  const runStep = async (step, suppressErrors) => {
3780
3882
  if (suppressErrors) {
3781
3883
  await step().catch(() => {});
@@ -3787,19 +3889,21 @@ function createRuntimeResourceLifecycle(params) {
3787
3889
  setTunnelResult: (result) => {
3788
3890
  tunnelResult = result;
3789
3891
  },
3790
- stop: async (opts) => {
3791
- if (stopped) return;
3792
- stopped = true;
3892
+ stop: (opts) => {
3893
+ if (stopPromise) return stopPromise;
3793
3894
  const suppressErrors = opts?.suppressErrors ?? false;
3794
- await runStep(async () => {
3795
- if (tunnelResult) await tunnelResult.stop();
3796
- }, suppressErrors);
3797
- await runStep(async () => {
3798
- await cleanupTailscaleExposure(params.config);
3799
- }, suppressErrors);
3800
- await runStep(async () => {
3801
- await params.webhookServer.stop();
3802
- }, suppressErrors);
3895
+ stopPromise = (async () => {
3896
+ await runStep(async () => {
3897
+ if (tunnelResult) await tunnelResult.stop();
3898
+ }, suppressErrors);
3899
+ await runStep(async () => {
3900
+ await cleanupTailscaleExposure(params.config);
3901
+ }, suppressErrors);
3902
+ await runStep(async () => {
3903
+ await params.webhookServer.stop();
3904
+ }, suppressErrors);
3905
+ })();
3906
+ return stopPromise;
3803
3907
  }
3804
3908
  };
3805
3909
  }
@@ -3937,6 +4041,7 @@ async function createVoiceCallRuntime(params) {
3937
4041
  tools: resolveRealtimeVoiceAgentConsultTools(config.realtime.toolPolicy, config.realtime.tools)
3938
4042
  }, manager, provider, realtimeProvider.provider, realtimeProvider.providerConfig, config.serve.path, cfg, resolveRealtimeInstructions);
3939
4043
  if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
4044
+ handlerContext.abortSignal?.throwIfAborted();
3940
4045
  const call = manager.getCall(callId);
3941
4046
  if (!call) return { error: `Call "${callId}" not found` };
3942
4047
  const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
@@ -3967,6 +4072,7 @@ async function createVoiceCallRuntime(params) {
3967
4072
  args,
3968
4073
  logger: log
3969
4074
  });
4075
+ handlerContext.abortSignal?.throwIfAborted();
3970
4076
  if (fastContext.handled) {
3971
4077
  assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
3972
4078
  return fastContext.result;
@@ -4003,7 +4109,8 @@ async function createVoiceCallRuntime(params) {
4003
4109
  spawnedBy: requesterSessionKey,
4004
4110
  contextMode: requesterSessionKey ? "fork" : void 0,
4005
4111
  toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(effectiveConfig.realtime.toolPolicy),
4006
- extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT
4112
+ extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
4113
+ abortSignal: handlerContext.abortSignal
4007
4114
  });
4008
4115
  });
4009
4116
  webhookServer.setRealtimeHandler(realtimeHandler);
@@ -4058,7 +4165,7 @@ async function createVoiceCallRuntime(params) {
4058
4165
  }
4059
4166
  if (realtimeProvider) log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
4060
4167
  await manager.initialize(provider, webhookUrl);
4061
- const stop = async () => await lifecycle.stop();
4168
+ const stop = () => lifecycle.stop();
4062
4169
  log.info("[voice-call] Runtime initialized");
4063
4170
  log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
4064
4171
  if (publicUrl && publicUrl !== webhookUrl) log.info(`[voice-call] Public URL: ${publicUrl}`);