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

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.
Files changed (141) hide show
  1. package/README.md +4 -3
  2. package/dist/doctor-contract-api.js +10 -5
  3. package/dist/{guarded-json-api-DEDwgf4J.js → guarded-json-api-CQXCe4aR.js} +1 -1
  4. package/dist/index.js +390 -338
  5. package/dist/{mock-BCq0q36l.js → mock-Dv7KxYGt.js} +3 -1
  6. package/dist/{plivo-DbI17uAc.js → plivo-Ct5BT5LQ.js} +2 -2
  7. package/dist/{realtime-handler-uXR0QgVa.js → realtime-handler-CSK1A1Gg.js} +270 -144
  8. package/dist/{response-generator-D-hkzo5Y.js → response-generator-xbT37lYx.js} +59 -12
  9. package/dist/{runtime-entry-F13eJ4pp.js → runtime-entry-q02qkX8F.js} +435 -231
  10. package/dist/runtime-entry.js +1 -1
  11. package/dist/{store-path-51_oC_xn.js → store-path-Cf6k3F2U.js} +3 -10
  12. package/dist/{telnyx-sIS_WgnY.js → telnyx-C9Yizvox.js} +12 -8
  13. package/dist/{twilio-CbeVrVAl.js → twilio-BLjVv246.js} +40 -27
  14. package/node_modules/typebox/build/format/date.d.mts +1 -1
  15. package/node_modules/typebox/build/format/date.mjs +1 -1
  16. package/node_modules/typebox/build/format/date_time.d.mts +2 -2
  17. package/node_modules/typebox/build/format/date_time.mjs +3 -3
  18. package/node_modules/typebox/build/format/email.d.mts +1 -1
  19. package/node_modules/typebox/build/format/email.mjs +2 -2
  20. package/node_modules/typebox/build/format/hostname.mjs +2 -10
  21. package/node_modules/typebox/build/format/idn_email.d.mts +1 -1
  22. package/node_modules/typebox/build/format/idn_email.mjs +3 -3
  23. package/node_modules/typebox/build/format/idn_hostname.d.mts +1 -0
  24. package/node_modules/typebox/build/format/idn_hostname.mjs +3 -11
  25. package/node_modules/typebox/build/format/idna/format/bidi.d.mts +3 -0
  26. package/node_modules/typebox/build/format/idna/format/bidi.mjs +98 -0
  27. package/node_modules/typebox/build/format/idna/format/puny.d.mts +3 -0
  28. package/node_modules/typebox/build/format/idna/format/puny.mjs +144 -0
  29. package/node_modules/typebox/build/format/idna/hostname.d.mts +1 -0
  30. package/node_modules/typebox/build/format/idna/hostname.mjs +16 -0
  31. package/node_modules/typebox/build/format/idna/idn-hostname.d.mts +1 -0
  32. package/node_modules/typebox/build/format/idna/idn-hostname.mjs +23 -0
  33. package/node_modules/typebox/build/format/idna/index.d.mts +2 -0
  34. package/node_modules/typebox/build/format/idna/index.mjs +2 -0
  35. package/node_modules/typebox/build/format/idna/label/ascii.d.mts +1 -0
  36. package/node_modules/typebox/build/format/idna/label/ascii.mjs +9 -0
  37. package/node_modules/typebox/build/format/idna/label/puny.d.mts +1 -0
  38. package/node_modules/typebox/build/format/idna/label/puny.mjs +28 -0
  39. package/node_modules/typebox/build/format/idna/label/unicode.d.mts +1 -0
  40. package/node_modules/typebox/build/format/idna/label/unicode.mjs +106 -0
  41. package/node_modules/typebox/build/format/idna/pattern/pattern.d.mts +30 -0
  42. package/node_modules/typebox/build/format/idna/pattern/pattern.mjs +70 -0
  43. package/node_modules/typebox/build/format/ipv4.d.mts +0 -1
  44. package/node_modules/typebox/build/format/ipv4.mjs +2 -32
  45. package/node_modules/typebox/build/format/ipv6.mjs +2 -61
  46. package/node_modules/typebox/build/format/iri.d.mts +2 -2
  47. package/node_modules/typebox/build/format/iri.mjs +26 -8
  48. package/node_modules/typebox/build/format/iri_reference.d.mts +2 -2
  49. package/node_modules/typebox/build/format/iri_reference.mjs +9 -55
  50. package/node_modules/typebox/build/format/regex.d.mts +1 -2
  51. package/node_modules/typebox/build/format/regex.mjs +2 -6
  52. package/node_modules/typebox/build/format/time.d.mts +2 -2
  53. package/node_modules/typebox/build/format/time.mjs +19 -14
  54. package/node_modules/typebox/build/format/uri.d.mts +1 -1
  55. package/node_modules/typebox/build/format/uri.mjs +3 -130
  56. package/node_modules/typebox/build/format/uri_reference.d.mts +1 -1
  57. package/node_modules/typebox/build/format/uri_reference.mjs +2 -3
  58. package/node_modules/typebox/build/format/uri_template.d.mts +2 -3
  59. package/node_modules/typebox/build/format/uri_template.mjs +4 -5
  60. package/node_modules/typebox/build/format/url.d.mts +3 -3
  61. package/node_modules/typebox/build/format/url.mjs +4 -5
  62. package/node_modules/typebox/build/guard/emit.d.mts +3 -1
  63. package/node_modules/typebox/build/guard/emit.mjs +12 -5
  64. package/node_modules/typebox/build/guard/guard.d.mts +8 -2
  65. package/node_modules/typebox/build/guard/guard.mjs +25 -4
  66. package/node_modules/typebox/build/guard/string.d.mts +2 -2
  67. package/node_modules/typebox/build/guard/string.mjs +7 -7
  68. package/node_modules/typebox/build/schema/engine/_context.mjs +3 -3
  69. package/node_modules/typebox/build/schema/engine/_functions.mjs +1 -2
  70. package/node_modules/typebox/build/schema/engine/_regexp.d.mts +2 -0
  71. package/node_modules/typebox/build/schema/engine/_regexp.mjs +4 -0
  72. package/node_modules/typebox/build/schema/engine/_stack.mjs +12 -13
  73. package/node_modules/typebox/build/schema/engine/additionalItems.mjs +17 -7
  74. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +4 -3
  75. package/node_modules/typebox/build/schema/engine/contains.mjs +18 -6
  76. package/node_modules/typebox/build/schema/engine/enum.mjs +1 -1
  77. package/node_modules/typebox/build/schema/engine/items.mjs +26 -6
  78. package/node_modules/typebox/build/schema/engine/maxContains.mjs +3 -3
  79. package/node_modules/typebox/build/schema/engine/minContains.mjs +14 -4
  80. package/node_modules/typebox/build/schema/engine/not.mjs +4 -2
  81. package/node_modules/typebox/build/schema/engine/oneOf.mjs +7 -6
  82. package/node_modules/typebox/build/schema/engine/pattern.mjs +3 -2
  83. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +4 -3
  84. package/node_modules/typebox/build/schema/engine/ref.mjs +1 -7
  85. package/node_modules/typebox/build/schema/engine/schema.mjs +13 -4
  86. package/node_modules/typebox/build/schema/engine/type.mjs +1 -1
  87. package/node_modules/typebox/build/schema/resolve/resolve.d.mts +4 -1
  88. package/node_modules/typebox/build/schema/resolve/resolve.mjs +202 -1
  89. package/node_modules/typebox/build/schema/static/propertyNames.d.mts +5 -0
  90. package/node_modules/typebox/build/schema/static/propertyNames.mjs +3 -0
  91. package/node_modules/typebox/build/schema/static/schema.d.mts +3 -0
  92. package/node_modules/typebox/build/schema/types/index.d.mts +0 -2
  93. package/node_modules/typebox/build/schema/types/index.mjs +0 -2
  94. package/node_modules/typebox/build/system/memory/clone.mjs +14 -11
  95. package/node_modules/typebox/build/system/memory/discard.mjs +6 -5
  96. package/node_modules/typebox/build/system/settings/settings.d.mts +9 -0
  97. package/node_modules/typebox/build/system/settings/settings.mjs +2 -0
  98. package/node_modules/typebox/build/type/engine/call/distribute_arguments.d.mts +6 -1
  99. package/node_modules/typebox/build/type/engine/call/distribute_arguments.mjs +16 -3
  100. package/node_modules/typebox/build/type/engine/call/instantiate.mjs +34 -4
  101. package/node_modules/typebox/build/type/engine/evaluate/broaden.d.mts +5 -5
  102. package/node_modules/typebox/build/type/engine/evaluate/broaden.mjs +20 -26
  103. package/node_modules/typebox/build/type/engine/evaluate/compare.d.mts +6 -10
  104. package/node_modules/typebox/build/type/engine/evaluate/compare.mjs +9 -13
  105. package/node_modules/typebox/build/type/engine/evaluate/composite.d.mts +7 -4
  106. package/node_modules/typebox/build/type/engine/evaluate/composite.mjs +12 -19
  107. package/node_modules/typebox/build/type/engine/evaluate/distribute.d.mts +4 -20
  108. package/node_modules/typebox/build/type/engine/evaluate/distribute.mjs +8 -20
  109. package/node_modules/typebox/build/type/engine/evaluate/evaluate.d.mts +4 -4
  110. package/node_modules/typebox/build/type/engine/evaluate/evaluate.mjs +11 -8
  111. package/node_modules/typebox/build/type/engine/evaluate/flatten.d.mts +1 -1
  112. package/node_modules/typebox/build/type/engine/evaluate/flatten.mjs +3 -4
  113. package/node_modules/typebox/build/type/engine/evaluate/narrow.d.mts +17 -2
  114. package/node_modules/typebox/build/type/engine/evaluate/narrow.mjs +26 -6
  115. package/node_modules/typebox/build/type/engine/exclude/operation.mjs +3 -4
  116. package/node_modules/typebox/build/type/engine/extract/operation.d.mts +1 -1
  117. package/node_modules/typebox/build/type/engine/extract/operation.mjs +3 -4
  118. package/node_modules/typebox/build/type/engine/priority/priority.d.mts +2 -2
  119. package/node_modules/typebox/build/type/engine/priority/priority.mjs +3 -4
  120. package/node_modules/typebox/build/type/script/mapping.d.mts +32 -29
  121. package/node_modules/typebox/build/type/script/mapping.mjs +18 -25
  122. package/node_modules/typebox/build/type/script/parser.d.mts +19 -25
  123. package/node_modules/typebox/build/type/script/parser.mjs +27 -23
  124. package/node_modules/typebox/build/value/errors/errors.d.mts +2 -2
  125. package/node_modules/typebox/build/value/errors/errors.mjs +1 -1
  126. package/node_modules/typebox/package.json +27 -27
  127. package/node_modules/typebox/readme.md +79 -82
  128. package/node_modules/ws/lib/permessage-deflate.js +3 -1
  129. package/node_modules/ws/package.json +1 -1
  130. package/openclaw.plugin.json +20 -2
  131. package/package.json +6 -6
  132. package/node_modules/typebox/build/format/_idna.d.mts +0 -2
  133. package/node_modules/typebox/build/format/_idna.mjs +0 -246
  134. package/node_modules/typebox/build/format/_puny.d.mts +0 -1
  135. package/node_modules/typebox/build/format/_puny.mjs +0 -77
  136. package/node_modules/typebox/build/schema/resolve/ref.d.mts +0 -3
  137. package/node_modules/typebox/build/schema/resolve/ref.mjs +0 -146
  138. package/node_modules/typebox/build/schema/types/contentEncoding.d.mts +0 -9
  139. package/node_modules/typebox/build/schema/types/contentEncoding.mjs +0 -13
  140. package/node_modules/typebox/build/schema/types/contentMediaType.d.mts +0 -9
  141. package/node_modules/typebox/build/schema/types/contentMediaType.mjs +0 -13
@@ -1,13 +1,13 @@
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 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";
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-Cf6k3F2U.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  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}`);
@@ -1570,7 +1646,7 @@ function resolveUserPath(input) {
1570
1646
  const trimmed = input.trim();
1571
1647
  if (!trimmed) return trimmed;
1572
1648
  if (trimmed.startsWith("~")) {
1573
- const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
1649
+ const expanded = trimmed.replace(/^~(?=$|[\\/])/, () => os.homedir());
1574
1650
  return path.resolve(expanded);
1575
1651
  }
1576
1652
  return path.resolve(trimmed);
@@ -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,57 +2172,74 @@ 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
2110
2241
  const NGROK_LOG_BUFFER_MAX_CHARS = 16384;
2111
2242
  const NGROK_ERROR_MARKER = "ERR_NGROK";
2112
- const TUNNEL_COMMAND_OUTPUT_MAX_BYTES = 16384;
2113
2243
  const NGROK_STOP_GRACE_MS = 2e3;
2114
2244
  const NGROK_FORCE_KILL_WAIT_MS = 1e3;
2115
2245
  async function terminateNgrokProcess(proc, isClosed) {
@@ -2149,11 +2279,6 @@ function listenForChildStreamErrors(proc, onError) {
2149
2279
  * // Later: await tunnel.stop();
2150
2280
  */
2151
2281
  async function startNgrokTunnel(config) {
2152
- if (config.authToken) await runNgrokCommand([
2153
- "config",
2154
- "add-authtoken",
2155
- config.authToken
2156
- ]);
2157
2282
  const args = [
2158
2283
  "http",
2159
2284
  String(config.port),
@@ -2164,11 +2289,17 @@ async function startNgrokTunnel(config) {
2164
2289
  ];
2165
2290
  if (config.domain) args.push("--domain", config.domain);
2166
2291
  return new Promise((resolve, reject) => {
2167
- const proc = spawn("ngrok", args, { stdio: [
2168
- "ignore",
2169
- "pipe",
2170
- "pipe"
2171
- ] });
2292
+ const proc = spawn("ngrok", args, {
2293
+ stdio: [
2294
+ "ignore",
2295
+ "pipe",
2296
+ "pipe"
2297
+ ],
2298
+ ...config.authToken ? { env: {
2299
+ ...process.env,
2300
+ NGROK_AUTHTOKEN: config.authToken
2301
+ } } : {}
2302
+ });
2172
2303
  let startupSettled = false;
2173
2304
  let childClosed = false;
2174
2305
  let publicUrl = null;
@@ -2241,86 +2372,40 @@ async function startNgrokTunnel(config) {
2241
2372
  });
2242
2373
  }
2243
2374
  /**
2244
- * Run an ngrok command and wait for completion.
2245
- */
2246
- async function runNgrokCommand(args) {
2247
- const result = await runCommandWithTimeout(["ngrok", ...args], {
2248
- killProcessTree: true,
2249
- maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
2250
- outputCapture: "tail",
2251
- timeoutMs: 3e4
2252
- });
2253
- if (result.termination === "timeout") throw new Error("ngrok command timed out");
2254
- if (result.code === 0) return result.stdout;
2255
- const output = result.stderr ? {
2256
- text: result.stderr,
2257
- truncated: Boolean(result.stderrTruncatedBytes)
2258
- } : {
2259
- text: result.stdout,
2260
- truncated: Boolean(result.stdoutTruncatedBytes)
2261
- };
2262
- throw new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`);
2263
- }
2264
- /**
2265
2375
  * Start a Tailscale serve/funnel tunnel.
2266
2376
  */
2267
2377
  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
2378
  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)
2379
+ const routes = [{
2380
+ publicPath: path,
2381
+ localPath: path
2382
+ }, ...config.streamPaths ?? []].map(({ publicPath, localPath }) => {
2383
+ const normalizedPublicPath = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
2384
+ const normalizedLocalPath = localPath.startsWith("/") ? localPath : `/${localPath}`;
2385
+ return {
2386
+ path: normalizedPublicPath,
2387
+ localUrl: `http://127.0.0.1:${config.port}${normalizedLocalPath}`
2294
2388
  };
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}`);
2389
+ });
2390
+ const publicUrl = await setupTailscaleExposureRoutes({
2391
+ mode: config.mode,
2392
+ port: config.tailscalePort,
2393
+ routes
2394
+ });
2395
+ if (!publicUrl) throw new Error(`Tailscale ${config.mode} failed`);
2300
2396
  return {
2301
2397
  publicUrl,
2302
2398
  provider: `tailscale-${config.mode}`,
2303
2399
  stop: async () => {
2304
- await stopTailscaleTunnel(config.mode, path);
2400
+ for (const route of routes) await cleanupTailscaleExposureRoute({
2401
+ mode: config.mode,
2402
+ port: config.tailscalePort,
2403
+ path: route.path
2404
+ });
2305
2405
  }
2306
2406
  };
2307
2407
  }
2308
2408
  /**
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
2409
  * Start a tunnel based on configuration.
2325
2410
  */
2326
2411
  async function startTunnel(config) {
@@ -2334,12 +2419,16 @@ async function startTunnel(config) {
2334
2419
  case "tailscale-serve": return startTailscaleTunnel({
2335
2420
  mode: "serve",
2336
2421
  port: config.port,
2337
- path: config.path
2422
+ tailscalePort: config.tailscalePort ?? 443,
2423
+ path: config.path,
2424
+ streamPaths: config.streamPaths
2338
2425
  });
2339
2426
  case "tailscale-funnel": return startTailscaleTunnel({
2340
2427
  mode: "funnel",
2341
2428
  port: config.port,
2342
- path: config.path
2429
+ tailscalePort: config.tailscalePort ?? 443,
2430
+ path: config.path,
2431
+ streamPaths: config.streamPaths
2343
2432
  });
2344
2433
  default: return null;
2345
2434
  }
@@ -2420,6 +2509,8 @@ const DEFAULT_MAX_CONNECTIONS = 128;
2420
2509
  const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
2421
2510
  const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
2422
2511
  const MAX_PENDING_TTS_OPERATIONS_PER_STREAM = 8;
2512
+ const MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM = 64;
2513
+ const PLAYBACK_MARK_TIMEOUT_GRACE_MS = 2e3;
2423
2514
  const CLOSE_REASON_LOG_MAX_CHARS = 120;
2424
2515
  function sanitizeLogText(value, maxChars) {
2425
2516
  const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
@@ -2454,6 +2545,8 @@ var MediaStreamHandler = class {
2454
2545
  this.ttsQueues = /* @__PURE__ */ new Map();
2455
2546
  this.ttsPlaying = /* @__PURE__ */ new Map();
2456
2547
  this.ttsActiveControllers = /* @__PURE__ */ new Map();
2548
+ this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
2549
+ this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
2457
2550
  this.config = config;
2458
2551
  this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
2459
2552
  this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
@@ -2577,8 +2670,10 @@ var MediaStreamHandler = class {
2577
2670
  session = null;
2578
2671
  }
2579
2672
  break;
2580
- case "clear":
2581
- case "mark": break;
2673
+ case "mark":
2674
+ if (session && message.mark?.name) this.acknowledgePlaybackMark(session.streamSid, message.mark.name);
2675
+ break;
2676
+ case "clear": break;
2582
2677
  }
2583
2678
  } catch (error) {
2584
2679
  console.error("[MediaStream] Error processing message:", error);
@@ -2904,10 +2999,44 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2904
2999
  mark: { name }
2905
3000
  });
2906
3001
  }
3002
+ /** Send a completion mark and wait until Twilio reports that buffered playback reached it. */
3003
+ async sendMarkAndWait(streamSid, name, audioDurationMs, signal) {
3004
+ signal.throwIfAborted();
3005
+ const marks = this.getPendingPlaybackMarks(streamSid);
3006
+ if (marks.has(name)) throw new Error(`Telephony playback mark is already pending: ${name}`);
3007
+ this.ignoredPlaybackMarks.get(streamSid)?.delete(name);
3008
+ let pending;
3009
+ const acknowledgement = new Promise((resolve, reject) => {
3010
+ const timeout = setTimeout(() => {
3011
+ console.warn(`[MediaStream] Playback mark timed out; continuing stream=${streamSid}`);
3012
+ pending.settle();
3013
+ }, Math.max(1, audioDurationMs + PLAYBACK_MARK_TIMEOUT_GRACE_MS));
3014
+ timeout.unref?.();
3015
+ const onAbort = () => {
3016
+ const reason = signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Telephony playback mark wait aborted");
3017
+ pending.settle(reason, true);
3018
+ };
3019
+ pending = { settle: (error, ignoreLateAck = false) => {
3020
+ if (marks.get(name) !== pending) return;
3021
+ clearTimeout(timeout);
3022
+ signal.removeEventListener("abort", onAbort);
3023
+ marks.delete(name);
3024
+ if (marks.size === 0) this.pendingPlaybackMarks.delete(streamSid);
3025
+ if (ignoreLateAck) this.ignorePlaybackMark(streamSid, name);
3026
+ if (error) reject(error);
3027
+ else resolve();
3028
+ } };
3029
+ marks.set(name, pending);
3030
+ signal.addEventListener("abort", onAbort, { once: true });
3031
+ });
3032
+ if (!this.sendMark(streamSid, name).sent) pending.settle(/* @__PURE__ */ new Error("Telephony stream playback failed: completion mark not delivered"));
3033
+ return acknowledgement;
3034
+ }
2907
3035
  /**
2908
3036
  * Clear audio buffer (interrupt playback).
2909
3037
  */
2910
3038
  clearAudio(streamSid) {
3039
+ this.invalidatePlaybackMarks(streamSid);
2911
3040
  return this.sendToStream(streamSid, {
2912
3041
  event: "clear",
2913
3042
  streamSid
@@ -2960,6 +3089,36 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2960
3089
  this.ttsQueues.set(streamSid, queue);
2961
3090
  return queue;
2962
3091
  }
3092
+ getPendingPlaybackMarks(streamSid) {
3093
+ const existing = this.pendingPlaybackMarks.get(streamSid);
3094
+ if (existing) return existing;
3095
+ const marks = /* @__PURE__ */ new Map();
3096
+ this.pendingPlaybackMarks.set(streamSid, marks);
3097
+ return marks;
3098
+ }
3099
+ acknowledgePlaybackMark(streamSid, name) {
3100
+ const ignored = this.ignoredPlaybackMarks.get(streamSid);
3101
+ if (ignored?.delete(name)) {
3102
+ if (ignored.size === 0) this.ignoredPlaybackMarks.delete(streamSid);
3103
+ return;
3104
+ }
3105
+ this.pendingPlaybackMarks.get(streamSid)?.get(name)?.settle();
3106
+ }
3107
+ invalidatePlaybackMarks(streamSid) {
3108
+ const marks = this.pendingPlaybackMarks.get(streamSid);
3109
+ if (!marks) return;
3110
+ for (const pending of marks.values()) pending.settle(/* @__PURE__ */ new Error("Telephony playback cleared before completion"), true);
3111
+ }
3112
+ ignorePlaybackMark(streamSid, name) {
3113
+ const ignored = this.ignoredPlaybackMarks.get(streamSid) ?? /* @__PURE__ */ new Set();
3114
+ ignored.add(name);
3115
+ while (ignored.size > MAX_IGNORED_PLAYBACK_MARKS_PER_STREAM) {
3116
+ const oldest = ignored.values().next().value;
3117
+ if (oldest === void 0) break;
3118
+ ignored.delete(oldest);
3119
+ }
3120
+ this.ignoredPlaybackMarks.set(streamSid, ignored);
3121
+ }
2963
3122
  /**
2964
3123
  * Process the TTS queue for a stream.
2965
3124
  * Uses iterative approach to avoid stack accumulation from recursion.
@@ -3055,6 +3214,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
3055
3214
  this.ttsActiveControllers.delete(streamSid);
3056
3215
  this.ttsPlaying.delete(streamSid);
3057
3216
  this.ttsQueues.delete(streamSid);
3217
+ this.invalidatePlaybackMarks(streamSid);
3218
+ this.ignoredPlaybackMarks.delete(streamSid);
3058
3219
  }
3059
3220
  resolveQueuedTtsEntries(queue) {
3060
3221
  const pending = queue.splice(0);
@@ -3121,7 +3282,9 @@ function startStaleCallReaper(params) {
3121
3282
  if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
3122
3283
  callsBeingReaped.add(call.callId);
3123
3284
  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) => {
3285
+ params.manager.endCall(call.callId).then((result) => {
3286
+ if (!result.success) console.warn(`[voice-call] Reaper failed to end call ${call.callId}: ${result.error ?? "unknown error"}`);
3287
+ }).catch((err) => {
3125
3288
  console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
3126
3289
  }).finally(() => {
3127
3290
  callsBeingReaped.delete(call.callId);
@@ -3134,13 +3297,51 @@ function startStaleCallReaper(params) {
3134
3297
  };
3135
3298
  }
3136
3299
  //#endregion
3300
+ //#region extensions/voice-call/src/webhook/stream-disconnect-grace.ts
3301
+ const STREAM_DISCONNECT_GRACE_MS = 2e3;
3302
+ /** Track the current carrier stream and expire disconnected calls after reconnect grace. */
3303
+ var StreamDisconnectGrace = class {
3304
+ constructor(onGraceExpired) {
3305
+ this.onGraceExpired = onGraceExpired;
3306
+ this.streams = /* @__PURE__ */ new Map();
3307
+ }
3308
+ connect(providerCallId, streamId) {
3309
+ const current = this.streams.get(providerCallId);
3310
+ if (current?.disconnectTimer) clearTimeout(current.disconnectTimer);
3311
+ this.streams.set(providerCallId, { streamId });
3312
+ }
3313
+ disconnect(providerCallId, streamId) {
3314
+ const current = this.streams.get(providerCallId);
3315
+ if (!current || current.streamId !== streamId || current.disconnectTimer) return;
3316
+ const disconnectTimer = setTimeout(() => {
3317
+ if (this.streams.get(providerCallId)?.disconnectTimer !== disconnectTimer) return;
3318
+ this.streams.delete(providerCallId);
3319
+ this.onGraceExpired({
3320
+ providerCallId,
3321
+ streamId
3322
+ });
3323
+ }, STREAM_DISCONNECT_GRACE_MS);
3324
+ disconnectTimer.unref?.();
3325
+ current.disconnectTimer = disconnectTimer;
3326
+ }
3327
+ retire(providerCallId, streamId) {
3328
+ const current = this.streams.get(providerCallId);
3329
+ if (!current || current.streamId !== streamId) return;
3330
+ if (current.disconnectTimer) clearTimeout(current.disconnectTimer);
3331
+ this.streams.delete(providerCallId);
3332
+ }
3333
+ close() {
3334
+ for (const state of this.streams.values()) if (state.disconnectTimer) clearTimeout(state.disconnectTimer);
3335
+ this.streams.clear();
3336
+ }
3337
+ };
3338
+ //#endregion
3137
3339
  //#region extensions/voice-call/src/webhook.ts
3138
3340
  const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
3139
3341
  const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
3140
3342
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
3141
- const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
3142
3343
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
3143
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D-hkzo5Y.js"));
3344
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-xbT37lYx.js"));
3144
3345
  function appendRecentTalkEventMetadata(call, event) {
3145
3346
  const metadata = call.metadata ?? {};
3146
3347
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -3220,7 +3421,6 @@ var VoiceCallWebhookServer = class {
3220
3421
  this.stopStaleCallReaper = null;
3221
3422
  this.webhookInFlightLimiter = createWebhookInFlightLimiter();
3222
3423
  this.mediaStreamHandler = null;
3223
- this.pendingDisconnectHangups = /* @__PURE__ */ new Map();
3224
3424
  this.realtimeHandler = null;
3225
3425
  this.replayResponses = /* @__PURE__ */ new Map();
3226
3426
  this.replayResponseCacheCalls = 0;
@@ -3244,6 +3444,16 @@ var VoiceCallWebhookServer = class {
3244
3444
  error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
3245
3445
  debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
3246
3446
  };
3447
+ this.streamDisconnectGrace = new StreamDisconnectGrace(({ providerCallId }) => {
3448
+ const call = this.manager.getCallByProviderCallId(providerCallId);
3449
+ if (!call) return;
3450
+ this.logger.info(`Call finalization requested reason=stream-disconnect-grace-expired callId=${call.callId} providerCallId=${providerCallId}`);
3451
+ this.manager.endCall(call.callId).then((result) => {
3452
+ if (!result.success) this.logger.warn(`Failed to auto-end call ${call.callId}: ${result.error ?? "unknown error"}`);
3453
+ }).catch((err) => {
3454
+ this.logger.warn(`Failed to auto-end call ${call.callId}: ${String(err)}`);
3455
+ });
3456
+ });
3247
3457
  }
3248
3458
  /**
3249
3459
  * Get the media stream handler (for wiring to provider).
@@ -3264,11 +3474,8 @@ var VoiceCallWebhookServer = class {
3264
3474
  setRealtimeHandler(handler) {
3265
3475
  this.realtimeHandler = handler;
3266
3476
  }
3267
- clearPendingDisconnectHangup(providerCallId) {
3268
- const existing = this.pendingDisconnectHangups.get(providerCallId);
3269
- if (!existing) return;
3270
- clearTimeout(existing);
3271
- this.pendingDisconnectHangups.delete(providerCallId);
3477
+ getStreamDisconnectLifecycle() {
3478
+ return this.streamDisconnectGrace;
3272
3479
  }
3273
3480
  resolveMediaStreamClientIp(request) {
3274
3481
  const remoteIp = request.socket.remoteAddress ?? void 0;
@@ -3382,7 +3589,7 @@ var VoiceCallWebhookServer = class {
3382
3589
  },
3383
3590
  onConnect: (callId, streamSid) => {
3384
3591
  this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
3385
- this.clearPendingDisconnectHangup(callId);
3592
+ this.streamDisconnectGrace.connect(callId, streamSid);
3386
3593
  if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
3387
3594
  },
3388
3595
  onTranscriptionReady: (callId) => {
@@ -3393,21 +3600,7 @@ var VoiceCallWebhookServer = class {
3393
3600
  onDisconnect: (callId, streamSid) => {
3394
3601
  this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
3395
3602
  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);
3603
+ this.streamDisconnectGrace.disconnect(callId, streamSid);
3411
3604
  }
3412
3605
  };
3413
3606
  this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
@@ -3485,8 +3678,7 @@ var VoiceCallWebhookServer = class {
3485
3678
  });
3486
3679
  });
3487
3680
  this.startPromise = null;
3488
- for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
3489
- this.pendingDisconnectHangups.clear();
3681
+ this.streamDisconnectGrace.close();
3490
3682
  if (this.stopStaleCallReaper) {
3491
3683
  this.stopStaleCallReaper();
3492
3684
  this.stopStaleCallReaper = null;
@@ -3498,8 +3690,7 @@ var VoiceCallWebhookServer = class {
3498
3690
  this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
3499
3691
  this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
3500
3692
  ]);
3501
- for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
3502
- this.pendingDisconnectHangups.clear();
3693
+ this.streamDisconnectGrace.close();
3503
3694
  if (this.server === server) this.server = null;
3504
3695
  this.listeningUrl = null;
3505
3696
  const failure = results.find((result) => result.status === "rejected");
@@ -3817,6 +4008,7 @@ var VoiceCallWebhookServer = class {
3817
4008
  callId,
3818
4009
  sessionKey: call.sessionKey,
3819
4010
  from: call.from,
4011
+ senderIsOwner: call.direction === "inbound" ? false : void 0,
3820
4012
  agentId: resolveCallAgentId(call, effectiveConfig),
3821
4013
  transcript: call.transcript,
3822
4014
  userMessage,
@@ -3848,12 +4040,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3848
4040
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3849
4041
  "Be accurate, brief, and speakable."
3850
4042
  ].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"));
4043
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-C9Yizvox.js"));
4044
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-BLjVv246.js"));
4045
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-Ct5BT5LQ.js"));
4046
+ const loadMockProvider = createLazyRuntimeModule(() => import("./mock-Dv7KxYGt.js"));
3855
4047
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
3856
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-uXR0QgVa.js"));
4048
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-CSK1A1Gg.js"));
3857
4049
  function resolveVoiceCallConsultSessionKey(call) {
3858
4050
  return resolveVoiceCallSessionKey({
3859
4051
  config: call.config,
@@ -3951,17 +4143,9 @@ async function resolveProvider(config) {
3951
4143
  default: throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
3952
4144
  }
3953
4145
  }
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
4146
  function listRealtimeAgentIds(config, coreConfig) {
3963
4147
  const agentIds = /* @__PURE__ */ new Set([normalizeAgentId(config.agentId)]);
3964
- for (const agent of coreConfig.agents?.list ?? []) agentIds.add(normalizeAgentId(agent.id));
4148
+ for (const agentId of listAgentIds(coreConfig)) agentIds.add(normalizeAgentId(agentId));
3965
4149
  for (const route of Object.values(config.numbers)) if (route.agentId) agentIds.add(normalizeAgentId(route.agentId));
3966
4150
  return [...agentIds];
3967
4151
  }
@@ -4024,22 +4208,37 @@ async function createVoiceCallRuntime(params) {
4024
4208
  const provider = await resolveProvider(config);
4025
4209
  if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
4026
4210
  const manager = new CallManager(config, void 0, cfg.session);
4027
- const realtimeProvider = config.realtime.enabled ? await resolveRealtimeProvider({
4028
- config,
4029
- fullConfig: cfg
4030
- }) : null;
4211
+ const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
4031
4212
  const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, fullConfig ?? coreConfig, agentRuntime, log);
4032
- if (realtimeProvider) {
4213
+ if (realtimeVoiceRuntime) {
4033
4214
  const { RealtimeCallHandler } = await loadRealtimeHandler();
4034
4215
  const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
4035
4216
  config,
4036
4217
  coreConfig: cfg,
4037
4218
  agentRuntime
4038
4219
  });
4039
- const realtimeHandler = new RealtimeCallHandler({
4220
+ const realtimeConfig = {
4040
4221
  ...config.realtime,
4041
- tools: resolveRealtimeVoiceAgentConsultTools(config.realtime.toolPolicy, config.realtime.tools)
4042
- }, manager, provider, realtimeProvider.provider, realtimeProvider.providerConfig, config.serve.path, cfg, resolveRealtimeInstructions);
4222
+ tools: resolveVoiceCallRealtimeTools(config.realtime.toolPolicy, config.realtime.tools)
4223
+ };
4224
+ const resolveCallRegistration = (call) => {
4225
+ const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
4226
+ const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
4227
+ const agentId = resolveCallAgentId(call, effectiveConfig);
4228
+ const resolved = realtimeVoiceRuntime.resolveConfiguredRealtimeVoiceProvider({
4229
+ configuredProviderId: effectiveConfig.realtime.provider,
4230
+ providerConfigs: effectiveConfig.realtime.providers,
4231
+ cfg,
4232
+ agentId
4233
+ });
4234
+ return {
4235
+ agentId,
4236
+ provider: resolved.provider,
4237
+ providerConfig: resolved.providerConfig,
4238
+ instructions: resolveRealtimeInstructions(call)
4239
+ };
4240
+ };
4241
+ const realtimeHandler = new RealtimeCallHandler(realtimeConfig, manager, resolveCallRegistration, config.serve.path, webhookServer.getStreamDisconnectLifecycle(), cfg);
4043
4242
  if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
4044
4243
  handlerContext.abortSignal?.throwIfAborted();
4045
4244
  const call = manager.getCall(callId);
@@ -4126,7 +4325,12 @@ async function createVoiceCallRuntime(params) {
4126
4325
  const nextTunnelResult = await startTunnel({
4127
4326
  provider: config.tunnel.provider,
4128
4327
  port: config.serve.port,
4328
+ tailscalePort: config.tailscale.port,
4129
4329
  path: config.serve.path,
4330
+ streamPaths: resolveVoiceCallStreamExposurePaths(config, {
4331
+ publicWebhookPath: config.serve.path,
4332
+ localWebhookPath: config.serve.path
4333
+ }),
4130
4334
  ngrokAuthToken: config.tunnel.ngrokAuthToken,
4131
4335
  ngrokDomain: config.tunnel.ngrokDomain
4132
4336
  });
@@ -4139,7 +4343,7 @@ async function createVoiceCallRuntime(params) {
4139
4343
  const webhookUrl = publicUrl ?? localUrl;
4140
4344
  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
4345
  if (publicUrl) provider.setPublicUrl?.(publicUrl);
4142
- if (publicUrl && realtimeProvider) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
4346
+ if (publicUrl && realtimeVoiceRuntime) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
4143
4347
  const realtimeHandler = webhookServer.getRealtimeHandler();
4144
4348
  if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
4145
4349
  if (provider.name === "twilio" && config.streaming?.enabled) {
@@ -4163,7 +4367,7 @@ async function createVoiceCallRuntime(params) {
4163
4367
  log.info("[voice-call] Media stream handler wired to provider");
4164
4368
  }
4165
4369
  }
4166
- if (realtimeProvider) log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
4370
+ if (realtimeVoiceRuntime) log.info("[voice-call] Realtime voice enabled");
4167
4371
  await manager.initialize(provider, webhookUrl);
4168
4372
  const stop = () => lifecycle.stop();
4169
4373
  log.info("[voice-call] Runtime initialized");
@@ -4184,4 +4388,4 @@ async function createVoiceCallRuntime(params) {
4184
4388
  }
4185
4389
  }
4186
4390
  //#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 };
4391
+ 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 };