@openclaw/voice-call 2026.8.1-beta.2 → 2026.8.1-beta.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.
@@ -7,7 +7,7 @@ import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
7
  import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
8
8
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
9
9
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
10
- import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
10
+ import { listAgentIds, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
11
11
  import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
12
12
  import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES, assertRealtimeVoiceAgentConsultModelSelectionUnlocked, buildRealtimeVoiceAgentConsultPolicyInstructions, consultRealtimeVoiceAgent, convertPcmToMulaw8k, createTalkSessionController, recordTalkObservabilityEvent, resolveRealtimeVoiceAgentConsultTools, resolveRealtimeVoiceAgentConsultToolsAllow, resolveRealtimeVoiceFastContextConsult } from "openclaw/plugin-sdk/realtime-voice";
13
13
  import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
@@ -16,11 +16,11 @@ import { canonicalizeMainSessionAlias } from "openclaw/plugin-sdk/session-store-
16
16
  import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
17
17
  import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
18
18
  import { z } from "zod";
19
+ import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
19
20
  import fs from "node:fs";
20
21
  import crypto from "node:crypto";
21
22
  import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
22
23
  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";
@@ -31,9 +31,51 @@ import { URL as URL$1 } from "node:url";
31
31
  import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
32
32
  import { WebSocket, WebSocketServer } from "ws";
33
33
  import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
34
+ //#region extensions/voice-call/src/realtime-call-control.ts
35
+ /** Stable provider-facing tool name for ending the current phone call. */
36
+ const REALTIME_VOICE_END_CALL_TOOL_NAME = "openclaw_end_call";
37
+ /** Closure-bound end-call control exposed on every realtime phone call. */
38
+ const REALTIME_VOICE_END_CALL_TOOL = {
39
+ type: "function",
40
+ name: REALTIME_VOICE_END_CALL_TOOL_NAME,
41
+ description: "End the current phone call immediately. Speak any final words to the caller before invoking this tool because the call ends as soon as it is invoked and no further reply will be spoken.",
42
+ parameters: {
43
+ type: "object",
44
+ properties: {}
45
+ }
46
+ };
47
+ /** Merge built-in call controls with consult and configured realtime tools. */
48
+ function resolveVoiceCallRealtimeTools(policy, customTools = []) {
49
+ const tools = resolveRealtimeVoiceAgentConsultTools(policy, customTools).filter((tool) => tool.name !== REALTIME_VOICE_END_CALL_TOOL_NAME);
50
+ return [REALTIME_VOICE_END_CALL_TOOL, ...tools];
51
+ }
52
+ //#endregion
34
53
  //#region extensions/voice-call/src/realtime-defaults.ts
35
54
  /** Baseline instructions that keep realtime calls brief and route deep work to agent consult. */
36
- const DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS = `You are OpenClaw's phone-call realtime voice interface. Keep spoken replies brief and natural. When a question needs deeper reasoning, current information, or tools, call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} before answering.`;
55
+ const DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS = `You are OpenClaw's phone-call realtime voice interface. Keep spoken replies brief and natural. When a question needs deeper reasoning, current information, or tools, call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} before answering. When the caller asks to end the call, speak any final words first, then call ${REALTIME_VOICE_END_CALL_TOOL_NAME}; it ends the call immediately and nothing after it will be spoken.`;
56
+ //#endregion
57
+ //#region extensions/voice-call/src/tailscale-config.ts
58
+ const VoiceCallTailscaleConfigSchema = z.object({
59
+ mode: z.enum([
60
+ "off",
61
+ "serve",
62
+ "funnel"
63
+ ]).default("off"),
64
+ port: z.number().int().positive().max(MAX_TCP_PORT).default(443),
65
+ path: z.string().min(1).default("/voice/webhook")
66
+ }).strict().default({
67
+ mode: "off",
68
+ port: 443,
69
+ path: "/voice/webhook"
70
+ });
71
+ const TAILSCALE_FUNNEL_HTTPS_PORTS = /* @__PURE__ */ new Set([
72
+ 443,
73
+ 8443,
74
+ 1e4
75
+ ]);
76
+ function isTailscalePortAllowed(config) {
77
+ return !(config.tailscale.mode === "funnel" || config.tunnel.provider === "tailscale-funnel") || TAILSCALE_FUNNEL_HTTPS_PORTS.has(config.tailscale.port);
78
+ }
37
79
  //#endregion
38
80
  //#region extensions/voice-call/src/config.ts
39
81
  /**
@@ -103,24 +145,6 @@ const VoiceCallServeConfigSchema = z.object({
103
145
  bind: "127.0.0.1",
104
146
  path: "/voice/webhook"
105
147
  });
106
- const VoiceCallTailscaleConfigSchema = z.object({
107
- /**
108
- * Tailscale exposure mode:
109
- * - "off": No Tailscale exposure
110
- * - "serve": Tailscale serve (private to tailnet)
111
- * - "funnel": Tailscale funnel (public HTTPS)
112
- */
113
- mode: z.enum([
114
- "off",
115
- "serve",
116
- "funnel"
117
- ]).default("off"),
118
- /** Path for Tailscale serve/funnel (should usually match serve.path) */
119
- path: z.string().min(1).default("/voice/webhook")
120
- }).strict().default({
121
- mode: "off",
122
- path: "/voice/webhook"
123
- });
124
148
  const VoiceCallTunnelConfigSchema = z.object({
125
149
  /**
126
150
  * Tunnel provider:
@@ -178,7 +202,11 @@ const VoiceCallWebhookSecurityConfigSchema = z.object({
178
202
  * - "conversation": Stay open for back-and-forth until explicit end or timeout
179
203
  */
180
204
  const CallModeSchema = z.enum(["notify", "conversation"]);
181
- const VoiceCallSessionScopeSchema = z.enum(["per-phone", "per-call"]);
205
+ const VoiceCallSessionScopeSchema = z.enum([
206
+ "per-phone",
207
+ "per-call",
208
+ "main"
209
+ ]);
182
210
  const OutboundConfigSchema = z.object({
183
211
  /** Default call mode for outbound calls */
184
212
  defaultMode: CallModeSchema.default("notify"),
@@ -418,7 +446,10 @@ const VoiceCallConfigSchema = z.object({
418
446
  responseSystemPrompt: z.string().optional(),
419
447
  /** Timeout for response generation in ms (default 30s) */
420
448
  responseTimeoutMs: z.number().int().positive().default(3e4)
421
- }).strict();
449
+ }).strict().refine(isTailscalePortAllowed, {
450
+ path: ["tailscale", "port"],
451
+ message: "Tailscale Funnel HTTPS port must be one of 443, 8443, 10000"
452
+ });
422
453
  const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
423
454
  const DEFAULT_VOICE_CALL_CONFIG = VoiceCallConfigSchema.parse({});
424
455
  function cloneDefaultVoiceCallConfig() {
@@ -430,6 +461,31 @@ function defaultRealtimeStreamPathForServePath(servePath) {
430
461
  if (normalized === "/") return "/voice/stream/realtime";
431
462
  return `${normalized}/stream/realtime`;
432
463
  }
464
+ function resolveVoiceCallPublicPathPrefix(publicWebhookPath, localWebhookPath) {
465
+ const publicPath = normalizeWebhookPath(publicWebhookPath);
466
+ const localPathIndex = publicPath.indexOf(normalizeWebhookPath(localWebhookPath));
467
+ return localPathIndex > 0 ? publicPath.slice(0, localPathIndex) : "";
468
+ }
469
+ function resolveVoiceCallStreamExposurePaths(config, webhookPaths = {}) {
470
+ const exposurePaths = [];
471
+ const localWebhookPath = webhookPaths.localWebhookPath ?? config.serve.path;
472
+ const publicPathPrefix = resolveVoiceCallPublicPathPrefix(webhookPaths.publicWebhookPath ?? config.tailscale.path, localWebhookPath);
473
+ if (config.realtime.enabled) {
474
+ const localPath = normalizeWebhookPath(config.realtime.streamPath ?? defaultRealtimeStreamPathForServePath(config.serve.path));
475
+ exposurePaths.push({
476
+ localPath,
477
+ publicPath: `${publicPathPrefix}${localPath}`
478
+ });
479
+ }
480
+ if (config.streaming.enabled) {
481
+ const localPath = normalizeWebhookPath(config.streaming.streamPath);
482
+ if (!exposurePaths.some((path) => path.localPath === localPath && path.publicPath === localPath)) exposurePaths.push({
483
+ localPath,
484
+ publicPath: localPath
485
+ });
486
+ }
487
+ return exposurePaths;
488
+ }
433
489
  function normalizeVoiceCallTtsConfig(defaults, overrides) {
434
490
  if (!defaults && !overrides) return;
435
491
  return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
@@ -553,6 +609,11 @@ function resolveVoiceCallSessionKey(params) {
553
609
  sessionKey: explicit,
554
610
  coreSession: params.coreSession
555
611
  });
612
+ if (params.config.sessionScope === "main") return resolveVoiceCallAgentSessionKey({
613
+ config: params.config,
614
+ sessionKey: "main",
615
+ coreSession: params.coreSession
616
+ });
556
617
  const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
557
618
  if (params.config.sessionScope === "per-call") return `${prefix}:call:${params.callId}`.toLowerCase();
558
619
  const normalizedPhone = params.phone?.replace(/\D/g, "");
@@ -734,9 +795,12 @@ function startMaxDurationTimer(params) {
734
795
  const call = params.ctx.activeCalls.get(params.callId);
735
796
  if (call && !TerminalStates.has(call.state)) {
736
797
  console.log(`[voice-call] Max duration reached (${Math.ceil(maxDurationMs / 1e3)}s), ending call ${params.callId}`);
737
- call.endReason = "timeout";
738
- persistCallRecord(params.ctx.storePath, call);
739
- await params.onTimeout(params.callId);
798
+ try {
799
+ const result = await params.onTimeout(params.callId);
800
+ if (!result.success) console.warn(`[voice-call] Failed to end max-duration call ${params.callId}: ${result.error ?? "unknown error"}`);
801
+ } catch (error) {
802
+ console.warn(`[voice-call] Failed to end max-duration call ${params.callId}: ${formatErrorMessage(error)}`);
803
+ }
740
804
  }
741
805
  })();
742
806
  }, maxDurationMs);
@@ -794,6 +858,7 @@ function waitForFinalTranscript(ctx, callId, turnToken) {
794
858
  }
795
859
  //#endregion
796
860
  //#region extensions/voice-call/src/manager/lifecycle.ts
861
+ const log$1 = createSubsystemLogger("voice-call/lifecycle");
797
862
  /** Remove a provider-call mapping only when it still points at this call. */
798
863
  function removeProviderCallMapping(providerCallIdMap, call) {
799
864
  if (!call.providerCallId) return;
@@ -802,10 +867,14 @@ function removeProviderCallMapping(providerCallIdMap, call) {
802
867
  /** Persist terminal state, clean timers/waiters, and remove active call indexes. */
803
868
  function finalizeCall(params) {
804
869
  const { ctx, call, endReason } = params;
805
- call.endedAt = params.endedAt ?? Date.now();
806
- call.endReason = endReason;
807
- transitionState(call, endReason);
808
- persistCallRecord(ctx.storePath, call);
870
+ const previousState = call.state;
871
+ if (!TerminalStates.has(previousState)) {
872
+ call.endedAt = params.endedAt ?? Date.now();
873
+ call.endReason = endReason;
874
+ transitionState(call, endReason);
875
+ persistCallRecord(ctx.storePath, call);
876
+ log$1.info(`[voice-call] Call finalized callId=${call.callId} providerCallId=${call.providerCallId ?? "unknown"} endReason=${endReason}`);
877
+ }
809
878
  if (ctx.maxDurationTimers) clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
810
879
  if (ctx.transcriptWaiters) rejectTranscriptWaiter({ transcriptWaiters: ctx.transcriptWaiters }, call.callId, params.transcriptRejectReason ?? `Call ended: ${endReason}`);
811
880
  ctx.activeCalls.delete(call.callId);
@@ -1073,9 +1142,7 @@ async function speak(ctx, callId, text, options) {
1073
1142
  ctx,
1074
1143
  call,
1075
1144
  liveAt: Date.now(),
1076
- onTimeout: async (id) => {
1077
- await endCall(ctx, id, { reason: "timeout" });
1078
- }
1145
+ onTimeout: (id) => endCall(ctx, id, { reason: "timeout" })
1079
1146
  });
1080
1147
  transitionState(call, "speaking");
1081
1148
  persistCallRecord(ctx.storePath, call);
@@ -1176,7 +1243,8 @@ async function speakInitialMessage(ctx, providerCallId) {
1176
1243
  const currentCall = ctx.activeCalls.get(call.callId);
1177
1244
  if (currentCall && !TerminalStates.has(currentCall.state)) {
1178
1245
  console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
1179
- await endCall(ctx, call.callId);
1246
+ const endResult = await endCall(ctx, call.callId);
1247
+ if (!endResult.success) console.warn(`[voice-call] Notify mode failed to hang up call ${call.callId}: ${endResult.error ?? "unknown error"}`);
1180
1248
  }
1181
1249
  })();
1182
1250
  }, delayMs);
@@ -1249,33 +1317,42 @@ async function continueCall(ctx, callId, prompt) {
1249
1317
  clearTranscriptWaiter(ctx, callId);
1250
1318
  }
1251
1319
  }
1252
- async function endCall(ctx, callId, options) {
1320
+ function endCall(ctx, callId, options) {
1321
+ const inFlight = ctx.endCallOperations.get(callId);
1322
+ if (inFlight) return inFlight;
1253
1323
  const lookup = lookupConnectedCall(ctx, callId);
1254
- if (lookup.kind === "error") return {
1324
+ if (lookup.kind === "error") return Promise.resolve({
1255
1325
  success: false,
1256
1326
  error: lookup.error
1257
- };
1258
- if (lookup.kind === "ended") return { success: true };
1327
+ });
1328
+ if (lookup.kind === "ended") return Promise.resolve({ success: true });
1259
1329
  const { call, providerCallId, provider } = lookup;
1260
1330
  const reason = options?.reason ?? "hangup-bot";
1261
- try {
1262
- await provider.hangupCall({
1263
- callId,
1264
- providerCallId,
1265
- reason
1266
- });
1267
- finalizeCall({
1268
- ctx,
1269
- call,
1270
- endReason: reason
1271
- });
1272
- return { success: true };
1273
- } catch (err) {
1274
- return {
1275
- success: false,
1276
- error: formatErrorMessage(err)
1277
- };
1278
- }
1331
+ const operation = (async () => {
1332
+ try {
1333
+ await provider.hangupCall({
1334
+ callId,
1335
+ providerCallId,
1336
+ reason
1337
+ });
1338
+ finalizeCall({
1339
+ ctx,
1340
+ call,
1341
+ endReason: reason
1342
+ });
1343
+ return { success: true };
1344
+ } catch (err) {
1345
+ return {
1346
+ success: false,
1347
+ error: formatErrorMessage(err)
1348
+ };
1349
+ }
1350
+ })();
1351
+ ctx.endCallOperations.set(callId, operation);
1352
+ operation.then(() => {
1353
+ if (ctx.endCallOperations.get(callId) === operation) ctx.endCallOperations.delete(callId);
1354
+ });
1355
+ return operation;
1279
1356
  }
1280
1357
  //#endregion
1281
1358
  //#region extensions/voice-call/src/manager/events.ts
@@ -1319,7 +1396,8 @@ function createWebhookCall(params) {
1319
1396
  sessionKey: resolveVoiceCallSessionKey({
1320
1397
  config: effectiveConfig,
1321
1398
  callId,
1322
- phone: params.direction === "outbound" ? params.to : params.from
1399
+ phone: params.direction === "outbound" ? params.to : params.from,
1400
+ coreSession: params.ctx.coreSession
1323
1401
  }),
1324
1402
  agentId: normalizeAgentId(effectiveConfig.agentId),
1325
1403
  startedAt: Date.now(),
@@ -1426,9 +1504,7 @@ function processEvent(ctx, event) {
1426
1504
  startMaxDurationTimer({
1427
1505
  ctx,
1428
1506
  callId: activeCall.callId,
1429
- onTimeout: async (callId) => {
1430
- await endCall(ctx, callId, { reason: "timeout" });
1431
- }
1507
+ onTimeout: (callId) => endCall(ctx, callId, { reason: "timeout" })
1432
1508
  });
1433
1509
  };
1434
1510
  const prepareLiveDurationTimer = () => {
@@ -1493,7 +1569,7 @@ function processEvent(ctx, event) {
1493
1569
  if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(activeCall, "bot", event.transcript);
1494
1570
  break;
1495
1571
  case "call.speech":
1496
- if (event.isFinal) {
1572
+ if (event.isFinal && event.transcript.trim()) {
1497
1573
  const waiter = ctx.transcriptWaiters.get(activeCall.callId);
1498
1574
  if (waiter?.turnToken && waiter.turnToken !== event.turnToken) {
1499
1575
  log.warn(`Ignoring speech event with mismatched turn token for ${activeCall.callId}`);
@@ -1606,6 +1682,7 @@ var CallManager = class {
1606
1682
  this.provider = null;
1607
1683
  this.webhookUrl = null;
1608
1684
  this.activeTurnCalls = /* @__PURE__ */ new Set();
1685
+ this.endCallOperations = /* @__PURE__ */ new Map();
1609
1686
  this.transcriptWaiters = /* @__PURE__ */ new Map();
1610
1687
  this.maxDurationTimers = /* @__PURE__ */ new Map();
1611
1688
  this.initialMessageInFlight = /* @__PURE__ */ new Set();
@@ -1648,9 +1725,7 @@ var CallManager = class {
1648
1725
  ctx: this.getContext(),
1649
1726
  callId,
1650
1727
  timeoutMs: maxDurationMs - elapsed,
1651
- onTimeout: async (id) => {
1652
- await endCall(this.getContext(), id, { reason: "timeout" });
1653
- }
1728
+ onTimeout: (id) => this.endCall(id, { reason: "timeout" })
1654
1729
  });
1655
1730
  console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
1656
1731
  }
@@ -1763,8 +1838,8 @@ var CallManager = class {
1763
1838
  /**
1764
1839
  * End an active call.
1765
1840
  */
1766
- async endCall(callId) {
1767
- return endCall(this.getContext(), callId);
1841
+ endCall(callId, options) {
1842
+ return endCall(this.getContext(), callId, options);
1768
1843
  }
1769
1844
  getContext() {
1770
1845
  return {
@@ -1778,6 +1853,7 @@ var CallManager = class {
1778
1853
  storePath: this.storePath,
1779
1854
  webhookUrl: this.webhookUrl,
1780
1855
  activeTurnCalls: this.activeTurnCalls,
1856
+ endCallOperations: this.endCallOperations,
1781
1857
  transcriptWaiters: this.transcriptWaiters,
1782
1858
  maxDurationTimers: this.maxDurationTimers,
1783
1859
  initialMessageInFlight: this.initialMessageInFlight,
@@ -1951,6 +2027,20 @@ function chunkAudio(audio, chunkSize = 160) {
1951
2027
  //#region extensions/voice-call/src/telephony-tts.ts
1952
2028
  /** Default timeout for one telephony synthesis request. */
1953
2029
  const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8e3;
2030
+ var UnsupportedTelephonyTtsOutputFormatError = class extends Error {
2031
+ constructor(outputFormat, provider) {
2032
+ super(`Unsupported telephony TTS output format "${outputFormat}" from provider "${provider}"`);
2033
+ this.outputFormat = outputFormat;
2034
+ this.provider = provider;
2035
+ this.name = "UnsupportedTelephonyTtsOutputFormatError";
2036
+ }
2037
+ };
2038
+ function convertTelephonyTtsOutput(result) {
2039
+ const format = result.outputFormat?.trim().toLowerCase();
2040
+ if ((format === "raw-8khz-8bit-mono-mulaw" || format === "ulaw_8000") && result.sampleRate === 8e3) return result.audioBuffer;
2041
+ if (!format || format === "pcm" || /^pcm[_-]\d+$/.test(format) || format.includes("raw") && (format.includes("16bit") || format.includes("16-bit")) && format.includes("pcm")) return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
2042
+ throw new UnsupportedTelephonyTtsOutputFormatError(result.outputFormat ?? "absent", result.provider ?? "unknown");
2043
+ }
1954
2044
  /** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
1955
2045
  async function createTelephonyTtsProvider(params) {
1956
2046
  const { coreConfig, ttsOverride, runtime, logger } = params;
@@ -1979,7 +2069,12 @@ async function createTelephonyTtsProvider(params) {
1979
2069
  const attemptedChain = result.attemptedProviders && result.attemptedProviders.length > 0 ? result.attemptedProviders.join(" -> ") : `${result.fallbackFrom} -> ${result.provider}`;
1980
2070
  logger?.warn?.(`[voice-call] Telephony TTS fallback used from=${result.fallbackFrom} to=${result.provider} attempts=${attemptedChain}`);
1981
2071
  }
1982
- return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
2072
+ return convertTelephonyTtsOutput({
2073
+ audioBuffer: result.audioBuffer,
2074
+ outputFormat: result.outputFormat,
2075
+ provider: result.provider,
2076
+ sampleRate: result.sampleRate
2077
+ });
1983
2078
  }
1984
2079
  };
1985
2080
  }
@@ -2012,6 +2107,24 @@ function formatBoundedChildOutput(output) {
2012
2107
  //#endregion
2013
2108
  //#region extensions/voice-call/src/webhook/tailscale.ts
2014
2109
  const TAILSCALE_COMMAND_STDOUT_MAX_BYTES = 4 * 1024 * 1024;
2110
+ const DEFAULT_TAILSCALE_HTTPS_PORT = 443;
2111
+ function buildTailscaleExposureArgs(opts) {
2112
+ if (!opts.localUrl && opts.port === DEFAULT_TAILSCALE_HTTPS_PORT) return [
2113
+ opts.mode,
2114
+ "off",
2115
+ opts.path
2116
+ ];
2117
+ const portArgs = opts.port === DEFAULT_TAILSCALE_HTTPS_PORT ? [] : ["--https", String(opts.port)];
2118
+ return [
2119
+ opts.mode,
2120
+ "--bg",
2121
+ "--yes",
2122
+ ...portArgs,
2123
+ "--set-path",
2124
+ opts.path,
2125
+ opts.localUrl ?? "off"
2126
+ ];
2127
+ }
2015
2128
  async function runTailscaleCommand(args, timeoutMs = 2500) {
2016
2129
  try {
2017
2130
  const result = await runCommandWithTimeout(["tailscale", ...args], {
@@ -2059,51 +2172,69 @@ async function getTailscaleSelfInfo() {
2059
2172
  async function getTailscaleDnsName() {
2060
2173
  return (await getTailscaleSelfInfo())?.dnsName ?? null;
2061
2174
  }
2062
- async function setupTailscaleExposureRoute(opts) {
2175
+ async function cleanupTailscaleExposureRoute(opts) {
2176
+ await runTailscaleCommand(buildTailscaleExposureArgs(opts));
2177
+ }
2178
+ async function setupTailscaleExposureRoutes(opts) {
2063
2179
  const dnsName = await getTailscaleDnsName();
2064
2180
  if (!dnsName) {
2065
2181
  console.warn("[voice-call] Could not get Tailscale DNS name");
2066
2182
  return null;
2067
2183
  }
2068
- const { code } = await runTailscaleCommand([
2069
- opts.mode,
2070
- "--bg",
2071
- "--yes",
2072
- "--set-path",
2073
- opts.path,
2074
- opts.localUrl
2075
- ]);
2076
- if (code === 0) {
2077
- const publicUrl = `https://${dnsName}${opts.path}`;
2078
- console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`);
2079
- return publicUrl;
2184
+ const mountedPaths = [];
2185
+ let publicUrl = null;
2186
+ for (const route of opts.routes) {
2187
+ const { code } = await runTailscaleCommand(buildTailscaleExposureArgs({
2188
+ mode: opts.mode,
2189
+ port: opts.port,
2190
+ ...route
2191
+ }));
2192
+ if (code !== 0) {
2193
+ for (const path of mountedPaths.toReversed()) await cleanupTailscaleExposureRoute({
2194
+ mode: opts.mode,
2195
+ port: opts.port,
2196
+ path
2197
+ });
2198
+ console.warn(`[voice-call] Tailscale ${opts.mode} exposure failed for ${route.path}; rolled back ${mountedPaths.length} mounted route(s)`);
2199
+ return null;
2200
+ }
2201
+ const routePublicUrl = `https://${dnsName}${opts.port === DEFAULT_TAILSCALE_HTTPS_PORT ? "" : `:${opts.port}`}${route.path}`;
2202
+ console.log(`[voice-call] Tailscale ${opts.mode} active: ${routePublicUrl}`);
2203
+ publicUrl ??= routePublicUrl;
2204
+ mountedPaths.push(route.path);
2080
2205
  }
2081
- console.warn(`[voice-call] Tailscale ${opts.mode} failed`);
2082
- return null;
2083
- }
2084
- async function cleanupTailscaleExposureRoute(opts) {
2085
- await runTailscaleCommand([
2086
- opts.mode,
2087
- "off",
2088
- opts.path
2089
- ]);
2206
+ return publicUrl;
2090
2207
  }
2091
2208
  async function setupTailscaleExposure(config) {
2092
2209
  if (config.tailscale.mode === "off") return null;
2093
2210
  const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
2094
2211
  const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
2095
- return setupTailscaleExposureRoute({
2212
+ const streamRoutes = resolveVoiceCallStreamExposurePaths(config).map(({ publicPath, localPath }) => ({
2213
+ path: publicPath,
2214
+ localUrl: `http://127.0.0.1:${config.serve.port}${localPath}`
2215
+ }));
2216
+ return setupTailscaleExposureRoutes({
2096
2217
  mode,
2097
- path: config.tailscale.path,
2098
- localUrl
2218
+ port: config.tailscale.port,
2219
+ routes: [{
2220
+ path: config.tailscale.path,
2221
+ localUrl
2222
+ }, ...streamRoutes]
2099
2223
  });
2100
2224
  }
2101
2225
  async function cleanupTailscaleExposure(config) {
2102
2226
  if (config.tailscale.mode === "off") return;
2227
+ const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
2103
2228
  await cleanupTailscaleExposureRoute({
2104
- mode: config.tailscale.mode === "funnel" ? "funnel" : "serve",
2229
+ mode,
2230
+ port: config.tailscale.port,
2105
2231
  path: config.tailscale.path
2106
2232
  });
2233
+ for (const { publicPath } of resolveVoiceCallStreamExposurePaths(config)) await cleanupTailscaleExposureRoute({
2234
+ mode,
2235
+ port: config.tailscale.port,
2236
+ path: publicPath
2237
+ });
2107
2238
  }
2108
2239
  //#endregion
2109
2240
  //#region extensions/voice-call/src/tunnel.ts
@@ -2265,62 +2396,37 @@ async function runNgrokCommand(args) {
2265
2396
  * Start a Tailscale serve/funnel tunnel.
2266
2397
  */
2267
2398
  async function startTailscaleTunnel(config) {
2268
- const dnsName = await getTailscaleDnsName();
2269
- if (!dnsName) throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
2270
2399
  const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
2271
- const localUrl = `http://127.0.0.1:${config.port}${path}`;
2272
- const result = await runCommandWithTimeout([
2273
- "tailscale",
2274
- config.mode,
2275
- "--bg",
2276
- "--yes",
2277
- "--set-path",
2278
- path,
2279
- localUrl
2280
- ], {
2281
- killProcessTree: true,
2282
- maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
2283
- outputCapture: "tail",
2284
- timeoutMs: 1e4
2285
- });
2286
- if (result.termination === "timeout") throw new Error(`Tailscale ${config.mode} timed out`);
2287
- if (result.code !== 0) {
2288
- const output = result.stderr ? {
2289
- text: result.stderr,
2290
- truncated: Boolean(result.stderrTruncatedBytes)
2291
- } : {
2292
- text: result.stdout,
2293
- truncated: Boolean(result.stdoutTruncatedBytes)
2400
+ const routes = [{
2401
+ publicPath: path,
2402
+ localPath: path
2403
+ }, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
2404
+ const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
2405
+ const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
2406
+ return {
2407
+ path: normalizedPublicPath,
2408
+ localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
2294
2409
  };
2295
- const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
2296
- throw new Error(`Tailscale ${config.mode} failed with code ${result.code}${detail}`);
2297
- }
2298
- const publicUrl = `https://${dnsName}${path}`;
2299
- console.log(`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`);
2410
+ });
2411
+ const publicUrl = await setupTailscaleExposureRoutes({
2412
+ mode: config.mode,
2413
+ port: config.tailscalePort,
2414
+ routes
2415
+ });
2416
+ if (!publicUrl) throw new Error(`Tailscale ${config.mode} failed`);
2300
2417
  return {
2301
2418
  publicUrl,
2302
2419
  provider: `tailscale-${config.mode}`,
2303
2420
  stop: async () => {
2304
- await stopTailscaleTunnel(config.mode, path);
2421
+ for (const route of routes) await cleanupTailscaleExposureRoute({
2422
+ mode: config.mode,
2423
+ port: config.tailscalePort,
2424
+ path: route.path
2425
+ });
2305
2426
  }
2306
2427
  };
2307
2428
  }
2308
2429
  /**
2309
- * Stop a Tailscale serve/funnel tunnel.
2310
- */
2311
- async function stopTailscaleTunnel(mode, path) {
2312
- await runCommandWithTimeout([
2313
- "tailscale",
2314
- mode,
2315
- "off",
2316
- path
2317
- ], {
2318
- killProcessTree: true,
2319
- maxOutputBytes: 1,
2320
- timeoutMs: 5e3
2321
- }).catch(() => {});
2322
- }
2323
- /**
2324
2430
  * Start a tunnel based on configuration.
2325
2431
  */
2326
2432
  async function startTunnel(config) {
@@ -2334,12 +2440,16 @@ async function startTunnel(config) {
2334
2440
  case "tailscale-serve": return startTailscaleTunnel({
2335
2441
  mode: "serve",
2336
2442
  port: config.port,
2337
- path: config.path
2443
+ tailscalePort: config.tailscalePort ?? 443,
2444
+ path: config.path,
2445
+ streamPaths: config.streamPaths
2338
2446
  });
2339
2447
  case "tailscale-funnel": return startTailscaleTunnel({
2340
2448
  mode: "funnel",
2341
2449
  port: config.port,
2342
- path: config.path
2450
+ tailscalePort: config.tailscalePort ?? 443,
2451
+ path: config.path,
2452
+ streamPaths: config.streamPaths
2343
2453
  });
2344
2454
  default: return null;
2345
2455
  }
@@ -2420,6 +2530,8 @@ const DEFAULT_MAX_CONNECTIONS = 128;
2420
2530
  const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
2421
2531
  const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
2422
2532
  const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
2533
+ const MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM = 64;
2534
+ const PLAYBACK_MARK_TIMEOUT_GRACE_MS = 2e3;
2423
2535
  const CLOSE_REASON_LOG_MAX_CHARS = 120;
2424
2536
  function sanitizeLogText(value, maxChars) {
2425
2537
  const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
@@ -2454,6 +2566,8 @@ var MediaStreamHandler = class {
2454
2566
  this.ttsQueues = /* @__PURE__ */ new Map();
2455
2567
  this.ttsPlaying = /* @__PURE__ */ new Map();
2456
2568
  this.ttsActiveControllers = /* @__PURE__ */ new Map();
2569
+ this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
2570
+ this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
2457
2571
  this.config = config;
2458
2572
  this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
2459
2573
  this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
@@ -2577,8 +2691,10 @@ var MediaStreamHandler = class {
2577
2691
  session = null;
2578
2692
  }
2579
2693
  break;
2580
- case "clear":
2581
- case "mark": break;
2694
+ case "mark":
2695
+ if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
2696
+ break;
2697
+ case "clear": break;
2582
2698
  }
2583
2699
  } catch (error) {
2584
2700
  console.error("[MediaStream] Error processing message:", error);
@@ -2904,10 +3020,44 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2904
3020
  mark: { name }
2905
3021
  });
2906
3022
  }
3023
+ /** Send a completion mark and wait until Twilio reports that buffered playback reached it. */
3024
+ async sendMarkAndWait(streamSid, name, audioDurationMs, signal) {
3025
+ signal.throwIfAborted();
3026
+ const marks = this.getPendingPlaybackMarks(streamSid);
3027
+ if (marks.has(name)) throw new Error(`Telephony playback mark is already pending: ${name}`);
3028
+ this.ignoredPlaybackMarks.get(streamSid)?.delete(name);
3029
+ let pending;
3030
+ const acknowledgement = new Promise((resolve, reject) => {
3031
+ const timeout = setTimeout(() => {
3032
+ console.warn(`[MediaStream] Playback mark timed out; continuing stream=${streamSid}`);
3033
+ pending.settle();
3034
+ }, Math.max(1, audioDurationMs + PLAYBACK_MARK_TIMEOUT_GRACE_MS));
3035
+ timeout.unref?.();
3036
+ const onAbort = () => {
3037
+ const reason = signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Telephony playback mark wait aborted");
3038
+ pending.settle(reason, true);
3039
+ };
3040
+ pending = { settle: (error, ignoreLateAck = false) => {
3041
+ if (marks.get(name) !== pending) return;
3042
+ clearTimeout(timeout);
3043
+ signal.removeEventListener("abort", onAbort);
3044
+ marks.delete(name);
3045
+ if (marks.size === 0) this.pendingPlaybackMarks.delete(streamSid);
3046
+ if (ignoreLateAck) this.ignorePlaybackMark(streamSid, name);
3047
+ if (error) reject(error);
3048
+ else resolve();
3049
+ } };
3050
+ marks.set(name, pending);
3051
+ signal.addEventListener("abort", onAbort, { once: true });
3052
+ });
3053
+ if (!this.sendMark(streamSid, name).sent) pending.settle(/* @__PURE__ */ new Error("Telephony stream playback failed: completion mark not delivered"));
3054
+ return acknowledgement;
3055
+ }
2907
3056
  /**
2908
3057
  * Clear audio buffer (interrupt playback).
2909
3058
  */
2910
3059
  clearAudio(streamSid) {
3060
+ this.invalidatePlaybackMarks(streamSid);
2911
3061
  return this.sendToStream(streamSid, {
2912
3062
  event: "clear",
2913
3063
  streamSid
@@ -2960,6 +3110,36 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2960
3110
  this.ttsQueues.set(streamSid, queue);
2961
3111
  return queue;
2962
3112
  }
3113
+ getPendingPlaybackMarks(streamSid) {
3114
+ const existing = this.pendingPlaybackMarks.get(streamSid);
3115
+ if (existing) return existing;
3116
+ const marks = /* @__PURE__ */ new Map();
3117
+ this.pendingPlaybackMarks.set(streamSid, marks);
3118
+ return marks;
3119
+ }
3120
+ acknowledgePlaybackMark(streamSid, name) {
3121
+ const ignored = this.ignoredPlaybackMarks.get(streamSid);
3122
+ if (ignored?.delete(name)) {
3123
+ if (ignored.size === 0) this.ignoredPlaybackMarks.delete(streamSid);
3124
+ return;
3125
+ }
3126
+ this.pendingPlaybackMarks.get(streamSid)?.get(name)?.settle();
3127
+ }
3128
+ invalidatePlaybackMarks(streamSid) {
3129
+ const marks = this.pendingPlaybackMarks.get(streamSid);
3130
+ if (!marks) return;
3131
+ for (const pending of marks.values()) pending.settle(/* @__PURE__ */ new Error("Telephony playback cleared before completion"), true);
3132
+ }
3133
+ ignorePlaybackMark(streamSid, name) {
3134
+ const ignored = this.ignoredPlaybackMarks.get(streamSid) ?? /* @__PURE__ */ new Set();
3135
+ ignored.add(name);
3136
+ while (ignored.size > MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM) {
3137
+ const oldest = ignored.values().next().value;
3138
+ if (oldest === void 0) break;
3139
+ ignored.delete(oldest);
3140
+ }
3141
+ this.ignoredPlaybackMarks.set(streamSid, ignored);
3142
+ }
2963
3143
  /**
2964
3144
  * Process the TTS queue for a stream.
2965
3145
  * Uses iterative approach to avoid stack accumulation from recursion.
@@ -3055,6 +3235,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
3055
3235
  this.ttsActiveControllers.delete(streamSid);
3056
3236
  this.ttsPlaying.delete(streamSid);
3057
3237
  this.ttsQueues.delete(streamSid);
3238
+ this.invalidatePlaybackMarks(streamSid);
3239
+ this.ignoredPlaybackMarks.delete(streamSid);
3058
3240
  }
3059
3241
  resolveQueuedTtsEntries(queue) {
3060
3242
  const pending = queue.splice(0);
@@ -3121,7 +3303,9 @@ function startStaleCallReaper(params) {
3121
3303
  if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
3122
3304
  callsBeingReaped.add(call.callId);
3123
3305
  console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
3124
- params.manager.endCall(call.callId).catch((err) => {
3306
+ params.manager.endCall(call.callId).then((result) => {
3307
+ if (!result.success) console.warn(`[voice-call] Reaper failed to end call ${call.callId}: ${result.error ?? "unknown error"}`);
3308
+ }).catch((err) => {
3125
3309
  console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
3126
3310
  }).finally(() => {
3127
3311
  callsBeingReaped.delete(call.callId);
@@ -3134,13 +3318,51 @@ function startStaleCallReaper(params) {
3134
3318
  };
3135
3319
  }
3136
3320
  //#endregion
3321
+ //#region extensions/voice-call/src/webhook/stream-disconnect-grace.ts
3322
+ const STREAM_DISCONNECT_GRACE_MS = 2e3;
3323
+ /** Track the current carrier stream and expire disconnected calls after reconnect grace. */
3324
+ var StreamDisconnectGrace = class {
3325
+ constructor(onGraceExpired) {
3326
+ this.onGraceExpired = onGraceExpired;
3327
+ this.streams = /* @__PURE__ */ new Map();
3328
+ }
3329
+ connect(providerCallId, streamId) {
3330
+ const current = this.streams.get(providerCallId);
3331
+ if (current?.disconnectTimer) clearTimeout(current.disconnectTimer);
3332
+ this.streams.set(providerCallId, { streamId });
3333
+ }
3334
+ disconnect(providerCallId, streamId) {
3335
+ const current = this.streams.get(providerCallId);
3336
+ if (!current || current.streamId !== streamId || current.disconnectTimer) return;
3337
+ const disconnectTimer = setTimeout(() => {
3338
+ if (this.streams.get(providerCallId)?.disconnectTimer !== disconnectTimer) return;
3339
+ this.streams.delete(providerCallId);
3340
+ this.onGraceExpired({
3341
+ providerCallId,
3342
+ streamId
3343
+ });
3344
+ }, STREAM_DISCONNECT_GRACE_MS);
3345
+ disconnectTimer.unref?.();
3346
+ current.disconnectTimer = disconnectTimer;
3347
+ }
3348
+ retire(providerCallId, streamId) {
3349
+ const current = this.streams.get(providerCallId);
3350
+ if (!current || current.streamId !== streamId) return;
3351
+ if (current.disconnectTimer) clearTimeout(current.disconnectTimer);
3352
+ this.streams.delete(providerCallId);
3353
+ }
3354
+ close() {
3355
+ for (const state of this.streams.values()) if (state.disconnectTimer) clearTimeout(state.disconnectTimer);
3356
+ this.streams.clear();
3357
+ }
3358
+ };
3359
+ //#endregion
3137
3360
  //#region extensions/voice-call/src/webhook.ts
3138
3361
  const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
3139
3362
  const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
3140
3363
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
3141
- const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
3142
3364
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
3143
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D-hkzo5Y.js"));
3365
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-tjyX4tDq.js"));
3144
3366
  function appendRecentTalkEventMetadata(call, event) {
3145
3367
  const metadata = call.metadata ?? {};
3146
3368
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -3220,7 +3442,6 @@ var VoiceCallWebhookServer = class {
3220
3442
  this.stopStaleCallReaper = null;
3221
3443
  this.webhookInFlightLimiter = createWebhookInFlightLimiter();
3222
3444
  this.mediaStreamHandler = null;
3223
- this.pendingDisconnectHangups = /* @__PURE__ */ new Map();
3224
3445
  this.realtimeHandler = null;
3225
3446
  this.replayResponses = /* @__PURE__ */ new Map();
3226
3447
  this.replayResponseCacheCalls = 0;
@@ -3244,6 +3465,16 @@ var VoiceCallWebhookServer = class {
3244
3465
  error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
3245
3466
  debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
3246
3467
  };
3468
+ this.streamDisconnectGrace = new StreamDisconnectGrace(({ providerCallId }) => {
3469
+ const call = this.manager.getCallByProviderCallId(providerCallId);
3470
+ if (!call) return;
3471
+ this.logger.info(`Call finalization requested reason=stream-disconnect-grace-expired callId=${call.callId} providerCallId=${providerCallId}`);
3472
+ this.manager.endCall(call.callId).then((result) => {
3473
+ if (!result.success) this.logger.warn(`Failed to auto-end call ${call.callId}: ${result.error ?? "unknown error"}`);
3474
+ }).catch((err) => {
3475
+ this.logger.warn(`Failed to auto-end call ${call.callId}: ${String(err)}`);
3476
+ });
3477
+ });
3247
3478
  }
3248
3479
  /**
3249
3480
  * Get the media stream handler (for wiring to provider).
@@ -3264,11 +3495,8 @@ var VoiceCallWebhookServer = class {
3264
3495
  setRealtimeHandler(handler) {
3265
3496
  this.realtimeHandler = handler;
3266
3497
  }
3267
- clearPendingDisconnectHangup(providerCallId) {
3268
- const existing = this.pendingDisconnectHangups.get(providerCallId);
3269
- if (!existing) return;
3270
- clearTimeout(existing);
3271
- this.pendingDisconnectHangups.delete(providerCallId);
3498
+ getStreamDisconnectLifecycle() {
3499
+ return this.streamDisconnectGrace;
3272
3500
  }
3273
3501
  resolveMediaStreamClientIp(request) {
3274
3502
  const remoteIp = request.socket.remoteAddress ?? void 0;
@@ -3382,7 +3610,7 @@ var VoiceCallWebhookServer = class {
3382
3610
  },
3383
3611
  onConnect: (callId, streamSid) => {
3384
3612
  this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
3385
- this.clearPendingDisconnectHangup(callId);
3613
+ this.streamDisconnectGrace.connect(callId, streamSid);
3386
3614
  if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
3387
3615
  },
3388
3616
  onTranscriptionReady: (callId) => {
@@ -3393,21 +3621,7 @@ var VoiceCallWebhookServer = class {
3393
3621
  onDisconnect: (callId, streamSid) => {
3394
3622
  this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
3395
3623
  if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
3396
- this.clearPendingDisconnectHangup(callId);
3397
- const timer = setTimeout(() => {
3398
- this.pendingDisconnectHangups.delete(callId);
3399
- const disconnectedCall = this.manager.getCallByProviderCallId(callId);
3400
- if (!disconnectedCall) return;
3401
- if (this.provider.name === "twilio") {
3402
- if (this.provider.hasRegisteredStream(callId)) return;
3403
- }
3404
- this.logger.info(`Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
3405
- this.manager.endCall(disconnectedCall.callId).catch((err) => {
3406
- this.logger.warn(`Failed to auto-end call ${disconnectedCall.callId}: ${String(err)}`);
3407
- });
3408
- }, STREAM_DISCONNECT_HANGUP_GRACE_MS);
3409
- timer.unref?.();
3410
- this.pendingDisconnectHangups.set(callId, timer);
3624
+ this.streamDisconnectGrace.disconnect(callId, streamSid);
3411
3625
  }
3412
3626
  };
3413
3627
  this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
@@ -3485,8 +3699,7 @@ var VoiceCallWebhookServer = class {
3485
3699
  });
3486
3700
  });
3487
3701
  this.startPromise = null;
3488
- for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
3489
- this.pendingDisconnectHangups.clear();
3702
+ this.streamDisconnectGrace.close();
3490
3703
  if (this.stopStaleCallReaper) {
3491
3704
  this.stopStaleCallReaper();
3492
3705
  this.stopStaleCallReaper = null;
@@ -3498,8 +3711,7 @@ var VoiceCallWebhookServer = class {
3498
3711
  this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
3499
3712
  this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
3500
3713
  ]);
3501
- for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
3502
- this.pendingDisconnectHangups.clear();
3714
+ this.streamDisconnectGrace.close();
3503
3715
  if (this.server === server) this.server = null;
3504
3716
  this.listeningUrl = null;
3505
3717
  const failure = results.find((result) => result.status === "rejected");
@@ -3817,6 +4029,7 @@ var VoiceCallWebhookServer = class {
3817
4029
  callId,
3818
4030
  sessionKey: call.sessionKey,
3819
4031
  from: call.from,
4032
+ senderIsOwner: call.direction === "inbound" ? false : void 0,
3820
4033
  agentId: resolveCallAgentId(call, effectiveConfig),
3821
4034
  transcript: call.transcript,
3822
4035
  userMessage,
@@ -3848,12 +4061,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3848
4061
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3849
4062
  "Be accurate, brief, and speakable."
3850
4063
  ].join(" ");
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"));
4064
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DO8dFQcb.js"));
4065
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-JDt_1nlz.js"));
4066
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BbVnahnQ.js"));
4067
+ const loadMockProvider = createLazyRuntimeModule(() => import("./mock-Dv7KxYGt.js"));
3855
4068
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
3856
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-uXR0QgVa.js"));
4069
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BaMEKbmJ.js"));
3857
4070
  function resolveVoiceCallConsultSessionKey(call) {
3858
4071
  return resolveVoiceCallSessionKey({
3859
4072
  config: call.config,
@@ -3951,17 +4164,9 @@ async function resolveProvider(config) {
3951
4164
  default: throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
3952
4165
  }
3953
4166
  }
3954
- async function resolveRealtimeProvider(params) {
3955
- const { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
3956
- return resolveConfiguredRealtimeVoiceProvider({
3957
- configuredProviderId: params.config.realtime.provider,
3958
- providerConfigs: params.config.realtime.providers,
3959
- cfg: params.fullConfig
3960
- });
3961
- }
3962
4167
  function listRealtimeAgentIds(config, coreConfig) {
3963
4168
  const agentIds = /* @__PURE__ */ new Set([normalizeAgentId(config.agentId)]);
3964
- for (const agent of coreConfig.agents?.list ?? []) agentIds.add(normalizeAgentId(agent.id));
4169
+ for (const agentId of listAgentIds(coreConfig)) agentIds.add(normalizeAgentId(agentId));
3965
4170
  for (const route of Object.values(config.numbers)) if (route.agentId) agentIds.add(normalizeAgentId(route.agentId));
3966
4171
  return [...agentIds];
3967
4172
  }
@@ -4024,22 +4229,37 @@ async function createVoiceCallRuntime(params) {
4024
4229
  const provider = await resolveProvider(config);
4025
4230
  if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
4026
4231
  const manager = new CallManager(config, void 0, cfg.session);
4027
- const realtimeProvider = config.realtime.enabled ? await resolveRealtimeProvider({
4028
- config,
4029
- fullConfig: cfg
4030
- }) : null;
4232
+ const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
4031
4233
  const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, fullConfig ?? coreConfig, agentRuntime, log);
4032
- if (realtimeProvider) {
4234
+ if (realtimeVoiceRuntime) {
4033
4235
  const { RealtimeCallHandler } = await loadRealtimeHandler();
4034
4236
  const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
4035
4237
  config,
4036
4238
  coreConfig: cfg,
4037
4239
  agentRuntime
4038
4240
  });
4039
- const realtimeHandler = new RealtimeCallHandler({
4241
+ const realtimeConfig = {
4040
4242
  ...config.realtime,
4041
- tools: resolveRealtimeVoiceAgentConsultTools(config.realtime.toolPolicy, config.realtime.tools)
4042
- }, manager, provider, realtimeProvider.provider, realtimeProvider.providerConfig, config.serve.path, cfg, resolveRealtimeInstructions);
4243
+ tools: resolveVoiceCallRealtimeTools(config.realtime.toolPolicy, config.realtime.tools)
4244
+ };
4245
+ const resolveCallRegistration = (call) => {
4246
+ const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
4247
+ const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
4248
+ const agentId = resolveCallAgentId(call, effectiveConfig);
4249
+ const resolved = realtimeVoiceRuntime.resolveConfiguredRealtimeVoiceProvider({
4250
+ configuredProviderId: effectiveConfig.realtime.provider,
4251
+ providerConfigs: effectiveConfig.realtime.providers,
4252
+ cfg,
4253
+ agentId
4254
+ });
4255
+ return {
4256
+ agentId,
4257
+ provider: resolved.provider,
4258
+ providerConfig: resolved.providerConfig,
4259
+ instructions: resolveRealtimeInstructions(call)
4260
+ };
4261
+ };
4262
+ const realtimeHandler = new RealtimeCallHandler(realtimeConfig, manager, resolveCallRegistration, config.serve.path, webhookServer.getStreamDisconnectLifecycle(), cfg);
4043
4263
  if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
4044
4264
  handlerContext.abortSignal?.throwIfAborted();
4045
4265
  const call = manager.getCall(callId);
@@ -4126,7 +4346,12 @@ async function createVoiceCallRuntime(params) {
4126
4346
  const nextTunnelResult = await startTunnel({
4127
4347
  provider: config.tunnel.provider,
4128
4348
  port: config.serve.port,
4349
+ tailscalePort: config.tailscale.port,
4129
4350
  path: config.serve.path,
4351
+ streamPaths: resolveVoiceCallStreamExposurePaths(config, {
4352
+ publicWebhookPath: config.serve.path,
4353
+ localWebhookPath: config.serve.path
4354
+ }),
4130
4355
  ngrokAuthToken: config.tunnel.ngrokAuthToken,
4131
4356
  ngrokDomain: config.tunnel.ngrokDomain
4132
4357
  });
@@ -4139,7 +4364,7 @@ async function createVoiceCallRuntime(params) {
4139
4364
  const webhookUrl = publicUrl ?? localUrl;
4140
4365
  if (providerRequiresPublicWebhook(provider.name) && isProviderUnreachableWebhookUrl(webhookUrl)) throw new Error(`[voice-call] ${provider.name} requires a publicly reachable webhook URL. Refusing to use local-only webhook ${webhookUrl}. Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.`);
4141
4366
  if (publicUrl) provider.setPublicUrl?.(publicUrl);
4142
- if (publicUrl && realtimeProvider) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
4367
+ if (publicUrl && realtimeVoiceRuntime) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
4143
4368
  const realtimeHandler = webhookServer.getRealtimeHandler();
4144
4369
  if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
4145
4370
  if (provider.name === "twilio" && config.streaming?.enabled) {
@@ -4163,7 +4388,7 @@ async function createVoiceCallRuntime(params) {
4163
4388
  log.info("[voice-call] Media stream handler wired to provider");
4164
4389
  }
4165
4390
  }
4166
- if (realtimeProvider) log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
4391
+ if (realtimeVoiceRuntime) log.info("[voice-call] Realtime voice enabled");
4167
4392
  await manager.initialize(provider, webhookUrl);
4168
4393
  const stop = () => lifecycle.stop();
4169
4394
  log.info("[voice-call] Runtime initialized");
@@ -4184,4 +4409,4 @@ async function createVoiceCallRuntime(params) {
4184
4409
  }
4185
4410
  }
4186
4411
  //#endregion
4187
- export { validateProviderConfig as S, escapeXml as _, normalizeProviderStatus as a, resolveVoiceCallConfig as b, resolveWebhookExposureStatus as c, setupTailscaleExposureRoute as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, resolveUserPath as g, resolveCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveVoiceResponseModel as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, chunkAudio as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, mapVoiceToPolly as v, resolveVoiceCallSessionKey as x, VoiceCallConfigSchema as y };
4412
+ export { resolveVoiceCallStreamExposurePaths as C, resolveVoiceCallSessionKey as S, REALTIME_VOICE_END_CALL_TOOL_NAME as T, escapeXml as _, normalizeProviderStatus as a, resolveVoiceCallConfig as b, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, resolveUserPath as g, resolveCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveVoiceResponseModel as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, chunkAudio as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, mapVoiceToPolly as v, validateProviderConfig as w, resolveVoiceCallPublicPathPrefix as x, VoiceCallConfigSchema as y };