@openclaw/voice-call 2026.7.2-beta.7 → 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.
@@ -1,14 +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 TerminalStates, d as loadActiveCallsFromStore, g as setVoiceCallStateRuntime, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-nYL-yM0S.js";
4
+ import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-51_oC_xn.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
- import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
8
7
  import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
9
8
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
10
9
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
11
- import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
10
+ import { listAgentIds, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime";
12
11
  import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
13
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";
14
13
  import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
@@ -17,6 +16,7 @@ import { canonicalizeMainSessionAlias } from "openclaw/plugin-sdk/session-store-
17
16
  import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
18
17
  import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
19
18
  import { z } from "zod";
19
+ import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
20
20
  import fs from "node:fs";
21
21
  import crypto from "node:crypto";
22
22
  import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
@@ -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);
@@ -1207,7 +1275,8 @@ async function continueCall(ctx, callId, prompt) {
1207
1275
  const turnStartedAt = Date.now();
1208
1276
  const turnToken = provider.name === "twilio" ? crypto.randomUUID() : void 0;
1209
1277
  try {
1210
- await speak(ctx, callId, prompt);
1278
+ const speakResult = await speak(ctx, callId, prompt);
1279
+ if (!speakResult.success) return speakResult;
1211
1280
  transitionState(call, "listening");
1212
1281
  persistCallRecord(ctx.storePath, call);
1213
1282
  const listenStartedAt = Date.now();
@@ -1248,33 +1317,42 @@ async function continueCall(ctx, callId, prompt) {
1248
1317
  clearTranscriptWaiter(ctx, callId);
1249
1318
  }
1250
1319
  }
1251
- async function endCall(ctx, callId, options) {
1320
+ function endCall(ctx, callId, options) {
1321
+ const inFlight = ctx.endCallOperations.get(callId);
1322
+ if (inFlight) return inFlight;
1252
1323
  const lookup = lookupConnectedCall(ctx, callId);
1253
- if (lookup.kind === "error") return {
1324
+ if (lookup.kind === "error") return Promise.resolve({
1254
1325
  success: false,
1255
1326
  error: lookup.error
1256
- };
1257
- if (lookup.kind === "ended") return { success: true };
1327
+ });
1328
+ if (lookup.kind === "ended") return Promise.resolve({ success: true });
1258
1329
  const { call, providerCallId, provider } = lookup;
1259
1330
  const reason = options?.reason ?? "hangup-bot";
1260
- try {
1261
- await provider.hangupCall({
1262
- callId,
1263
- providerCallId,
1264
- reason
1265
- });
1266
- finalizeCall({
1267
- ctx,
1268
- call,
1269
- endReason: reason
1270
- });
1271
- return { success: true };
1272
- } catch (err) {
1273
- return {
1274
- success: false,
1275
- error: formatErrorMessage(err)
1276
- };
1277
- }
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;
1278
1356
  }
1279
1357
  //#endregion
1280
1358
  //#region extensions/voice-call/src/manager/events.ts
@@ -1318,7 +1396,8 @@ function createWebhookCall(params) {
1318
1396
  sessionKey: resolveVoiceCallSessionKey({
1319
1397
  config: effectiveConfig,
1320
1398
  callId,
1321
- phone: params.direction === "outbound" ? params.to : params.from
1399
+ phone: params.direction === "outbound" ? params.to : params.from,
1400
+ coreSession: params.ctx.coreSession
1322
1401
  }),
1323
1402
  agentId: normalizeAgentId(effectiveConfig.agentId),
1324
1403
  startedAt: Date.now(),
@@ -1329,9 +1408,9 @@ function createWebhookCall(params) {
1329
1408
  ...effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}
1330
1409
  }
1331
1410
  };
1411
+ persistCallRecord(params.ctx.storePath, callRecord);
1332
1412
  params.ctx.activeCalls.set(callId, callRecord);
1333
1413
  params.ctx.providerCallIdMap.set(params.providerCallId, callId);
1334
- persistCallRecord(params.ctx.storePath, callRecord);
1335
1414
  log.info(`Created ${params.direction} call record: ${callId} caller=${redactIdentifier(params.from)}`);
1336
1415
  return callRecord;
1337
1416
  }
@@ -1372,9 +1451,7 @@ function processEvent(ctx, event) {
1372
1451
  log.warn(`Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, caller=${redactIdentifier(event.from)}); call will time out on provider side.`);
1373
1452
  return { kind: "ignored" };
1374
1453
  }
1375
- ctx.processedEventIds.add(dedupeKey);
1376
1454
  if (ctx.rejectedProviderCallIds.has(pid)) return { kind: "ignored" };
1377
- ctx.rejectedProviderCallIds.add(pid);
1378
1455
  const callId = event.callId ?? pid;
1379
1456
  persistRejectedInboundCall({
1380
1457
  ctx,
@@ -1382,13 +1459,16 @@ function processEvent(ctx, event) {
1382
1459
  dedupeKey,
1383
1460
  providerCallId: pid
1384
1461
  });
1462
+ const rejectionReservation = reserveRejectedProviderCall(ctx.rejectedProviderCallIds, pid);
1463
+ if (rejectionReservation === void 0) return { kind: "ignored" };
1464
+ rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1385
1465
  log.info(`Rejecting inbound call by policy: ${pid}`);
1386
1466
  ctx.provider.hangupCall({
1387
1467
  callId,
1388
1468
  providerCallId: pid,
1389
1469
  reason: "hangup-bot"
1390
1470
  }).catch((err) => {
1391
- ctx.rejectedProviderCallIds.delete(pid);
1471
+ releaseRejectedProviderCall(ctx.rejectedProviderCallIds, pid, rejectionReservation);
1392
1472
  const message = formatErrorMessage(err);
1393
1473
  log.warn(`Failed to reject inbound call ${pid}: ${message}`);
1394
1474
  });
@@ -1403,126 +1483,160 @@ function processEvent(ctx, event) {
1403
1483
  });
1404
1484
  event.callId = call.callId;
1405
1485
  }
1406
- if (!call) return { kind: "ignored" };
1407
- if (event.providerCallId && event.providerCallId !== call.providerCallId) {
1408
- const previousProviderCallId = call.providerCallId;
1409
- call.providerCallId = event.providerCallId;
1410
- ctx.providerCallIdMap.set(event.providerCallId, call.callId);
1411
- if (previousProviderCallId) {
1412
- if (ctx.providerCallIdMap.get(previousProviderCallId) === call.callId) ctx.providerCallIdMap.delete(previousProviderCallId);
1413
- }
1414
- }
1415
- if (!(event.type === "call.error" && event.retryable)) {
1416
- ctx.processedEventIds.add(dedupeKey);
1417
- call.processedEventIds.push(dedupeKey);
1418
- }
1486
+ if (!call) return {
1487
+ kind: "ignored",
1488
+ replayable: true
1489
+ };
1490
+ const activeCall = call;
1491
+ const previousCall = {
1492
+ state: activeCall.state,
1493
+ providerCallId: activeCall.providerCallId,
1494
+ answeredAt: activeCall.answeredAt,
1495
+ endedAt: activeCall.endedAt,
1496
+ endReason: activeCall.endReason,
1497
+ transcriptLength: activeCall.transcript.length,
1498
+ processedEventIds: [...activeCall.processedEventIds]
1499
+ };
1500
+ const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
1501
+ const effects = [];
1419
1502
  let result = { kind: "processed" };
1420
- switch (event.type) {
1421
- case "call.initiated":
1422
- transitionState(call, "initiated");
1423
- if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
1424
- const inboundStreamSession = ctx.config.realtime?.enabled && ctx.provider.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
1425
- providerName: "telnyx",
1426
- callId: call.callId,
1427
- from: call.from,
1428
- to: call.to,
1429
- direction: "inbound"
1430
- }) : void 0;
1431
- ctx.provider.answerCall({
1432
- callId: call.callId,
1433
- providerCallId: call.providerCallId,
1434
- ...inboundStreamSession ? {
1435
- streamUrl: inboundStreamSession.streamUrl,
1436
- streamAuthToken: inboundStreamSession.token
1437
- } : {}
1438
- }).catch((err) => {
1439
- const message = formatErrorMessage(err);
1440
- log.warn(`Failed to answer inbound call ${call.providerCallId}: ${message}`);
1503
+ const startDurationTimer = () => {
1504
+ startMaxDurationTimer({
1505
+ ctx,
1506
+ callId: activeCall.callId,
1507
+ onTimeout: (callId) => endCall(ctx, callId, { reason: "timeout" })
1508
+ });
1509
+ };
1510
+ const prepareLiveDurationTimer = () => {
1511
+ if (!activeCall.answeredAt) {
1512
+ activeCall.answeredAt = event.timestamp;
1513
+ effects.push(startDurationTimer);
1514
+ }
1515
+ };
1516
+ const publishProviderCallId = (terminal = false) => {
1517
+ if (!event.providerCallId || event.providerCallId === previousCall.providerCallId) return;
1518
+ if (!terminal) ctx.providerCallIdMap.set(event.providerCallId, activeCall.callId);
1519
+ if (previousCall.providerCallId) {
1520
+ if (ctx.providerCallIdMap.get(previousCall.providerCallId) === activeCall.callId) ctx.providerCallIdMap.delete(previousCall.providerCallId);
1521
+ }
1522
+ };
1523
+ try {
1524
+ if (event.providerCallId && event.providerCallId !== activeCall.providerCallId) activeCall.providerCallId = event.providerCallId;
1525
+ if (shouldCommitReplayKey) appendCallReplayKey(activeCall.processedEventIds, dedupeKey);
1526
+ switch (event.type) {
1527
+ case "call.initiated": {
1528
+ transitionState(activeCall, "initiated");
1529
+ const inboundProvider = ctx.provider;
1530
+ const inboundProviderCallId = activeCall.providerCallId;
1531
+ const answerInboundCall = inboundProvider?.answerCall?.bind(inboundProvider);
1532
+ if (activeCall.direction === "inbound" && inboundProviderCallId && answerInboundCall) effects.push(() => {
1533
+ const inboundStreamSession = ctx.config.realtime?.enabled && inboundProvider?.name === "telnyx" && ctx.streamSessionIssuer ? ctx.streamSessionIssuer({
1534
+ providerName: "telnyx",
1535
+ callId: activeCall.callId,
1536
+ from: activeCall.from,
1537
+ to: activeCall.to,
1538
+ direction: "inbound"
1539
+ }) : void 0;
1540
+ answerInboundCall({
1541
+ callId: activeCall.callId,
1542
+ providerCallId: inboundProviderCallId,
1543
+ ...inboundStreamSession ? {
1544
+ streamUrl: inboundStreamSession.streamUrl,
1545
+ streamAuthToken: inboundStreamSession.token
1546
+ } : {}
1547
+ }).catch((err) => {
1548
+ const message = formatErrorMessage(err);
1549
+ log.warn(`Failed to answer inbound call ${activeCall.providerCallId}: ${message}`);
1550
+ });
1441
1551
  });
1552
+ break;
1442
1553
  }
1443
- break;
1444
- case "call.ringing":
1445
- transitionState(call, "ringing");
1446
- break;
1447
- case "call.answered":
1448
- call.answeredAt = event.timestamp;
1449
- transitionState(call, "answered");
1450
- startMaxDurationTimer({
1451
- ctx,
1452
- callId: call.callId,
1453
- onTimeout: async (callId) => {
1454
- await endCall(ctx, callId, { reason: "timeout" });
1455
- }
1456
- });
1457
- ctx.onCallAnswered?.(call);
1458
- break;
1459
- case "call.active":
1460
- transitionState(call, "active");
1461
- break;
1462
- case "call.speaking":
1463
- case "call.assistant-speech":
1464
- ensureMaxDurationTimerForLiveCall({
1465
- ctx,
1466
- call,
1467
- liveAt: event.timestamp,
1468
- onTimeout: async (callId) => {
1469
- await endCall(ctx, callId, { reason: "timeout" });
1470
- }
1471
- });
1472
- transitionState(call, "speaking");
1473
- if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(call, "bot", event.transcript);
1474
- break;
1475
- case "call.speech":
1476
- if (event.isFinal) {
1477
- const hadWaiter = ctx.transcriptWaiters.has(call.callId);
1478
- const resolved = resolveTranscriptWaiter(ctx, call.callId, event.transcript, event.turnToken);
1479
- if (hadWaiter && !resolved) {
1480
- log.warn(`Ignoring speech event with mismatched turn token for ${call.callId}`);
1481
- result = { kind: "ignored" };
1482
- break;
1483
- }
1484
- addTranscriptEntry(call, "user", event.transcript);
1485
- result = {
1486
- kind: "final-speech",
1487
- call,
1488
- transcript: event.transcript,
1489
- waiterResolved: resolved
1490
- };
1491
- }
1492
- ensureMaxDurationTimerForLiveCall({
1493
- ctx,
1494
- call,
1495
- liveAt: event.timestamp,
1496
- onTimeout: async (callId) => {
1497
- await endCall(ctx, callId, { reason: "timeout" });
1554
+ case "call.ringing":
1555
+ transitionState(activeCall, "ringing");
1556
+ break;
1557
+ case "call.answered":
1558
+ activeCall.answeredAt = event.timestamp;
1559
+ transitionState(activeCall, "answered");
1560
+ effects.push(startDurationTimer, () => ctx.onCallAnswered?.(activeCall));
1561
+ break;
1562
+ case "call.active":
1563
+ transitionState(activeCall, "active");
1564
+ break;
1565
+ case "call.speaking":
1566
+ case "call.assistant-speech":
1567
+ prepareLiveDurationTimer();
1568
+ transitionState(activeCall, "speaking");
1569
+ if (event.type === "call.assistant-speech" && event.transcript.trim()) addTranscriptEntry(activeCall, "bot", event.transcript);
1570
+ break;
1571
+ case "call.speech":
1572
+ if (event.isFinal && event.transcript.trim()) {
1573
+ const waiter = ctx.transcriptWaiters.get(activeCall.callId);
1574
+ if (waiter?.turnToken && waiter.turnToken !== event.turnToken) {
1575
+ log.warn(`Ignoring speech event with mismatched turn token for ${activeCall.callId}`);
1576
+ result = { kind: "ignored" };
1577
+ break;
1578
+ }
1579
+ addTranscriptEntry(activeCall, "user", event.transcript);
1580
+ result = {
1581
+ kind: "final-speech",
1582
+ call: activeCall,
1583
+ transcript: event.transcript,
1584
+ waiterResolved: Boolean(waiter)
1585
+ };
1586
+ if (waiter) effects.push(() => {
1587
+ resolveTranscriptWaiter(ctx, activeCall.callId, event.transcript, event.turnToken);
1588
+ });
1498
1589
  }
1499
- });
1500
- transitionState(call, "listening");
1501
- break;
1502
- case "call.silence":
1503
- case "call.dtmf": break;
1504
- case "call.ended":
1505
- finalizeCall({
1506
- ctx,
1507
- call,
1508
- endReason: event.reason,
1509
- endedAt: event.timestamp
1510
- });
1511
- return { kind: "processed" };
1512
- case "call.error":
1513
- if (!event.retryable) {
1590
+ prepareLiveDurationTimer();
1591
+ transitionState(activeCall, "listening");
1592
+ break;
1593
+ case "call.silence":
1594
+ case "call.dtmf": break;
1595
+ case "call.ended":
1514
1596
  finalizeCall({
1515
1597
  ctx,
1516
- call,
1517
- endReason: "error",
1518
- endedAt: event.timestamp,
1519
- transcriptRejectReason: `Call error: ${event.error}`
1598
+ call: activeCall,
1599
+ endReason: event.reason,
1600
+ endedAt: event.timestamp
1520
1601
  });
1602
+ publishProviderCallId(true);
1603
+ rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1521
1604
  return { kind: "processed" };
1522
- }
1523
- break;
1605
+ case "call.error":
1606
+ if (!event.retryable) {
1607
+ finalizeCall({
1608
+ ctx,
1609
+ call: activeCall,
1610
+ endReason: "error",
1611
+ endedAt: event.timestamp,
1612
+ transcriptRejectReason: `Call error: ${event.error}`
1613
+ });
1614
+ publishProviderCallId(true);
1615
+ rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1616
+ return { kind: "processed" };
1617
+ }
1618
+ result = {
1619
+ kind: "processed",
1620
+ replayable: true
1621
+ };
1622
+ break;
1623
+ }
1624
+ persistCallRecord(ctx.storePath, activeCall);
1625
+ publishProviderCallId();
1626
+ if (shouldCommitReplayKey) rememberManagerReplayKey(ctx.processedEventIds, dedupeKey);
1627
+ } catch (err) {
1628
+ Object.assign(activeCall, {
1629
+ state: previousCall.state,
1630
+ providerCallId: previousCall.providerCallId,
1631
+ answeredAt: previousCall.answeredAt,
1632
+ endedAt: previousCall.endedAt,
1633
+ endReason: previousCall.endReason
1634
+ });
1635
+ activeCall.transcript.length = previousCall.transcriptLength;
1636
+ activeCall.processedEventIds.splice(0, activeCall.processedEventIds.length, ...previousCall.processedEventIds);
1637
+ throw err;
1524
1638
  }
1525
- persistCallRecord(ctx.storePath, call);
1639
+ for (const effect of effects) effect();
1526
1640
  return result;
1527
1641
  }
1528
1642
  //#endregion
@@ -1564,10 +1678,11 @@ var CallManager = class {
1564
1678
  this.activeCalls = /* @__PURE__ */ new Map();
1565
1679
  this.providerCallIdMap = /* @__PURE__ */ new Map();
1566
1680
  this.processedEventIds = /* @__PURE__ */ new Set();
1567
- this.rejectedProviderCallIds = /* @__PURE__ */ new Set();
1681
+ this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
1568
1682
  this.provider = null;
1569
1683
  this.webhookUrl = null;
1570
1684
  this.activeTurnCalls = /* @__PURE__ */ new Set();
1685
+ this.endCallOperations = /* @__PURE__ */ new Map();
1571
1686
  this.transcriptWaiters = /* @__PURE__ */ new Map();
1572
1687
  this.maxDurationTimers = /* @__PURE__ */ new Map();
1573
1688
  this.initialMessageInFlight = /* @__PURE__ */ new Set();
@@ -1585,7 +1700,7 @@ var CallManager = class {
1585
1700
  fs.mkdirSync(this.storePath, { recursive: true });
1586
1701
  const persisted = loadActiveCallsFromStore(this.storePath);
1587
1702
  this.processedEventIds = persisted.processedEventIds;
1588
- this.rejectedProviderCallIds = persisted.rejectedProviderCallIds;
1703
+ this.rejectedProviderCallIds = /* @__PURE__ */ new Map();
1589
1704
  const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls);
1590
1705
  this.activeCalls = verified;
1591
1706
  this.providerCallIdMap = /* @__PURE__ */ new Map();
@@ -1610,9 +1725,7 @@ var CallManager = class {
1610
1725
  ctx: this.getContext(),
1611
1726
  callId,
1612
1727
  timeoutMs: maxDurationMs - elapsed,
1613
- onTimeout: async (id) => {
1614
- await endCall(this.getContext(), id, { reason: "timeout" });
1615
- }
1728
+ onTimeout: (id) => this.endCall(id, { reason: "timeout" })
1616
1729
  });
1617
1730
  console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
1618
1731
  }
@@ -1725,8 +1838,8 @@ var CallManager = class {
1725
1838
  /**
1726
1839
  * End an active call.
1727
1840
  */
1728
- async endCall(callId) {
1729
- return endCall(this.getContext(), callId);
1841
+ endCall(callId, options) {
1842
+ return endCall(this.getContext(), callId, options);
1730
1843
  }
1731
1844
  getContext() {
1732
1845
  return {
@@ -1740,6 +1853,7 @@ var CallManager = class {
1740
1853
  storePath: this.storePath,
1741
1854
  webhookUrl: this.webhookUrl,
1742
1855
  activeTurnCalls: this.activeTurnCalls,
1856
+ endCallOperations: this.endCallOperations,
1743
1857
  transcriptWaiters: this.transcriptWaiters,
1744
1858
  maxDurationTimers: this.maxDurationTimers,
1745
1859
  initialMessageInFlight: this.initialMessageInFlight,
@@ -1913,6 +2027,20 @@ function chunkAudio(audio, chunkSize = 160) {
1913
2027
  //#region extensions/voice-call/src/telephony-tts.ts
1914
2028
  /** Default timeout for one telephony synthesis request. */
1915
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
+ }
1916
2044
  /** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
1917
2045
  async function createTelephonyTtsProvider(params) {
1918
2046
  const { coreConfig, ttsOverride, runtime, logger } = params;
@@ -1941,7 +2069,12 @@ async function createTelephonyTtsProvider(params) {
1941
2069
  const attemptedChain = result.attemptedProviders && result.attemptedProviders.length > 0 ? result.attemptedProviders.join(" -> ") : `${result.fallbackFrom} -> ${result.provider}`;
1942
2070
  logger?.warn?.(`[voice-call] Telephony TTS fallback used from=${result.fallbackFrom} to=${result.provider} attempts=${attemptedChain}`);
1943
2071
  }
1944
- 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
+ });
1945
2078
  }
1946
2079
  };
1947
2080
  }
@@ -1974,6 +2107,24 @@ function formatBoundedChildOutput(output) {
1974
2107
  //#endregion
1975
2108
  //#region extensions/voice-call/src/webhook/tailscale.ts
1976
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
+ }
1977
2128
  async function runTailscaleCommand(args, timeoutMs = 2500) {
1978
2129
  try {
1979
2130
  const result = await runCommandWithTimeout(["tailscale", ...args], {
@@ -2021,51 +2172,69 @@ async function getTailscaleSelfInfo() {
2021
2172
  async function getTailscaleDnsName() {
2022
2173
  return (await getTailscaleSelfInfo())?.dnsName ?? null;
2023
2174
  }
2024
- async function setupTailscaleExposureRoute(opts) {
2175
+ async function cleanupTailscaleExposureRoute(opts) {
2176
+ await runTailscaleCommand(buildTailscaleExposureArgs(opts));
2177
+ }
2178
+ async function setupTailscaleExposureRoutes(opts) {
2025
2179
  const dnsName = await getTailscaleDnsName();
2026
2180
  if (!dnsName) {
2027
2181
  console.warn("[voice-call] Could not get Tailscale DNS name");
2028
2182
  return null;
2029
2183
  }
2030
- const { code } = await runTailscaleCommand([
2031
- opts.mode,
2032
- "--bg",
2033
- "--yes",
2034
- "--set-path",
2035
- opts.path,
2036
- opts.localUrl
2037
- ]);
2038
- if (code === 0) {
2039
- const publicUrl = `https://${dnsName}${opts.path}`;
2040
- console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`);
2041
- 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);
2042
2205
  }
2043
- console.warn(`[voice-call] Tailscale ${opts.mode} failed`);
2044
- return null;
2045
- }
2046
- async function cleanupTailscaleExposureRoute(opts) {
2047
- await runTailscaleCommand([
2048
- opts.mode,
2049
- "off",
2050
- opts.path
2051
- ]);
2206
+ return publicUrl;
2052
2207
  }
2053
2208
  async function setupTailscaleExposure(config) {
2054
2209
  if (config.tailscale.mode === "off") return null;
2055
2210
  const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
2056
2211
  const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
2057
- 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({
2058
2217
  mode,
2059
- path: config.tailscale.path,
2060
- localUrl
2218
+ port: config.tailscale.port,
2219
+ routes: [{
2220
+ path: config.tailscale.path,
2221
+ localUrl
2222
+ }, ...streamRoutes]
2061
2223
  });
2062
2224
  }
2063
2225
  async function cleanupTailscaleExposure(config) {
2064
2226
  if (config.tailscale.mode === "off") return;
2227
+ const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
2065
2228
  await cleanupTailscaleExposureRoute({
2066
- mode: config.tailscale.mode === "funnel" ? "funnel" : "serve",
2229
+ mode,
2230
+ port: config.tailscale.port,
2067
2231
  path: config.tailscale.path
2068
2232
  });
2233
+ for (const { publicPath } of resolveVoiceCallStreamExposurePaths(config)) await cleanupTailscaleExposureRoute({
2234
+ mode,
2235
+ port: config.tailscale.port,
2236
+ path: publicPath
2237
+ });
2069
2238
  }
2070
2239
  //#endregion
2071
2240
  //#region extensions/voice-call/src/tunnel.ts
@@ -2227,62 +2396,37 @@ async function runNgrokCommand(args) {
2227
2396
  * Start a Tailscale serve/funnel tunnel.
2228
2397
  */
2229
2398
  async function startTailscaleTunnel(config) {
2230
- const dnsName = await getTailscaleDnsName();
2231
- if (!dnsName) throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
2232
2399
  const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
2233
- const localUrl = `http://127.0.0.1:${config.port}${path}`;
2234
- const result = await runCommandWithTimeout([
2235
- "tailscale",
2236
- config.mode,
2237
- "--bg",
2238
- "--yes",
2239
- "--set-path",
2240
- path,
2241
- localUrl
2242
- ], {
2243
- killProcessTree: true,
2244
- maxOutputBytes: TUNNEL_COMMAND_OUTPUT_MAX_BYTES,
2245
- outputCapture: "tail",
2246
- timeoutMs: 1e4
2247
- });
2248
- if (result.termination === "timeout") throw new Error(`Tailscale ${config.mode} timed out`);
2249
- if (result.code !== 0) {
2250
- const output = result.stderr ? {
2251
- text: result.stderr,
2252
- truncated: Boolean(result.stderrTruncatedBytes)
2253
- } : {
2254
- text: result.stdout,
2255
- 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}`
2256
2409
  };
2257
- const detail = output.text ? `: ${formatBoundedChildOutput(output)}` : "";
2258
- throw new Error(`Tailscale ${config.mode} failed with code ${result.code}${detail}`);
2259
- }
2260
- const publicUrl = `https://${dnsName}${path}`;
2261
- 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`);
2262
2417
  return {
2263
2418
  publicUrl,
2264
2419
  provider: `tailscale-${config.mode}`,
2265
2420
  stop: async () => {
2266
- 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
+ });
2267
2426
  }
2268
2427
  };
2269
2428
  }
2270
2429
  /**
2271
- * Stop a Tailscale serve/funnel tunnel.
2272
- */
2273
- async function stopTailscaleTunnel(mode, path) {
2274
- await runCommandWithTimeout([
2275
- "tailscale",
2276
- mode,
2277
- "off",
2278
- path
2279
- ], {
2280
- killProcessTree: true,
2281
- maxOutputBytes: 1,
2282
- timeoutMs: 5e3
2283
- }).catch(() => {});
2284
- }
2285
- /**
2286
2430
  * Start a tunnel based on configuration.
2287
2431
  */
2288
2432
  async function startTunnel(config) {
@@ -2296,12 +2440,16 @@ async function startTunnel(config) {
2296
2440
  case "tailscale-serve": return startTailscaleTunnel({
2297
2441
  mode: "serve",
2298
2442
  port: config.port,
2299
- path: config.path
2443
+ tailscalePort: config.tailscalePort ?? 443,
2444
+ path: config.path,
2445
+ streamPaths: config.streamPaths
2300
2446
  });
2301
2447
  case "tailscale-funnel": return startTailscaleTunnel({
2302
2448
  mode: "funnel",
2303
2449
  port: config.port,
2304
- path: config.path
2450
+ tailscalePort: config.tailscalePort ?? 443,
2451
+ path: config.path,
2452
+ streamPaths: config.streamPaths
2305
2453
  });
2306
2454
  default: return null;
2307
2455
  }
@@ -2381,6 +2529,9 @@ const DEFAULT_MAX_PENDING_CONNECTIONS_PER_IP = 4;
2381
2529
  const DEFAULT_MAX_CONNECTIONS = 128;
2382
2530
  const MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
2383
2531
  const MAX_WS_BUFFERED_BYTES = 1024 * 1024;
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;
2384
2535
  const CLOSE_REASON_LOG_MAX_CHARS = 120;
2385
2536
  function sanitizeLogText(value, maxChars) {
2386
2537
  const sanitized = value.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
@@ -2406,6 +2557,8 @@ function parseTwilioMediaMessage(data) {
2406
2557
  var MediaStreamHandler = class {
2407
2558
  constructor(config) {
2408
2559
  this.wss = null;
2560
+ this.closePromise = null;
2561
+ this.closing = false;
2409
2562
  this.sessions = /* @__PURE__ */ new Map();
2410
2563
  this.pendingConnections = /* @__PURE__ */ new Map();
2411
2564
  this.pendingByIp = /* @__PURE__ */ new Map();
@@ -2413,6 +2566,8 @@ var MediaStreamHandler = class {
2413
2566
  this.ttsQueues = /* @__PURE__ */ new Map();
2414
2567
  this.ttsPlaying = /* @__PURE__ */ new Map();
2415
2568
  this.ttsActiveControllers = /* @__PURE__ */ new Map();
2569
+ this.pendingPlaybackMarks = /* @__PURE__ */ new Map();
2570
+ this.ignoredPlaybackMarks = /* @__PURE__ */ new Map();
2416
2571
  this.config = config;
2417
2572
  this.preStartTimeoutMs = resolveTimerTimeoutMs(config.preStartTimeoutMs, DEFAULT_PRE_START_TIMEOUT_MS);
2418
2573
  this.maxPendingConnections = config.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS;
@@ -2423,6 +2578,10 @@ var MediaStreamHandler = class {
2423
2578
  * Handle WebSocket upgrade for media stream connections.
2424
2579
  */
2425
2580
  handleUpgrade(request, socket, head) {
2581
+ if (this.closing) {
2582
+ this.rejectUpgrade(socket, 503, "Media stream handler is shutting down");
2583
+ return;
2584
+ }
2426
2585
  if (!this.wss) {
2427
2586
  this.wss = new WebSocketServer({
2428
2587
  noServer: true,
@@ -2464,6 +2623,23 @@ var MediaStreamHandler = class {
2464
2623
  throw error;
2465
2624
  }
2466
2625
  }
2626
+ close(shutdownBarrier = Promise.resolve()) {
2627
+ if (this.closePromise) return this.closePromise;
2628
+ this.closing = true;
2629
+ const wss = this.wss;
2630
+ this.wss = null;
2631
+ this.closePromise = (async () => {
2632
+ if (wss) await new Promise((resolve) => {
2633
+ wss.close(() => resolve());
2634
+ for (const ws of wss.clients) ws.terminate();
2635
+ });
2636
+ await shutdownBarrier;
2637
+ })().finally(() => {
2638
+ this.closing = false;
2639
+ this.closePromise = null;
2640
+ });
2641
+ return this.closePromise;
2642
+ }
2467
2643
  /**
2468
2644
  * Handle new WebSocket connection from Twilio.
2469
2645
  */
@@ -2483,6 +2659,11 @@ var MediaStreamHandler = class {
2483
2659
  console.log("[MediaStream] Twilio connected");
2484
2660
  break;
2485
2661
  case "start":
2662
+ if (session) {
2663
+ console.warn("[MediaStream] Rejecting duplicate start frame for active connection");
2664
+ ws.close(1008, "Duplicate start");
2665
+ break;
2666
+ }
2486
2667
  session = this.handleStart(ws, message, streamToken);
2487
2668
  if (session) this.clearPendingConnection(ws);
2488
2669
  break;
@@ -2510,8 +2691,10 @@ var MediaStreamHandler = class {
2510
2691
  session = null;
2511
2692
  }
2512
2693
  break;
2513
- case "clear":
2514
- 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;
2515
2698
  }
2516
2699
  } catch (error) {
2517
2700
  console.error("[MediaStream] Error processing message:", error);
@@ -2837,10 +3020,44 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2837
3020
  mark: { name }
2838
3021
  });
2839
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
+ }
2840
3056
  /**
2841
3057
  * Clear audio buffer (interrupt playback).
2842
3058
  */
2843
3059
  clearAudio(streamSid) {
3060
+ this.invalidatePlaybackMarks(streamSid);
2844
3061
  return this.sendToStream(streamSid, {
2845
3062
  event: "clear",
2846
3063
  streamSid
@@ -2852,6 +3069,7 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2852
3069
  */
2853
3070
  async queueTts(streamSid, playFn) {
2854
3071
  const queue = this.getTtsQueue(streamSid);
3072
+ if (queue.length >= MAX_PENDING_TTS_OPERATIONS_PER_STREAM) throw new Error(`Telephony TTS queue is full for stream; maxPending=${MAX_PENDING_TTS_OPERATIONS_PER_STREAM}`);
2855
3073
  let resolveEntry;
2856
3074
  let rejectEntry;
2857
3075
  const promise = new Promise((resolve, reject) => {
@@ -2871,8 +3089,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2871
3089
  * Clear TTS queue and interrupt current playback (barge-in).
2872
3090
  */
2873
3091
  clearTtsQueue(streamSid, _reason = "unspecified") {
2874
- const queue = this.getTtsQueue(streamSid);
2875
- this.resolveQueuedTtsEntries(queue);
3092
+ const queue = this.ttsQueues.get(streamSid);
3093
+ if (queue) this.resolveQueuedTtsEntries(queue);
2876
3094
  this.ttsActiveControllers.get(streamSid)?.abort();
2877
3095
  const session = this.sessions.get(streamSid);
2878
3096
  if (session?.talk.activeTurnId) {
@@ -2892,6 +3110,36 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2892
3110
  this.ttsQueues.set(streamSid, queue);
2893
3111
  return queue;
2894
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
+ }
2895
3143
  /**
2896
3144
  * Process the TTS queue for a stream.
2897
3145
  * Uses iterative approach to avoid stack accumulation from recursion.
@@ -2901,8 +3149,9 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2901
3149
  while (true) {
2902
3150
  const queue = this.ttsQueues.get(streamSid);
2903
3151
  if (!queue || queue.length === 0) {
2904
- this.ttsPlaying.set(streamSid, false);
3152
+ this.ttsPlaying.delete(streamSid);
2905
3153
  this.ttsActiveControllers.delete(streamSid);
3154
+ this.ttsQueues.delete(streamSid);
2906
3155
  return;
2907
3156
  }
2908
3157
  const entry = queue.shift();
@@ -2986,6 +3235,8 @@ Content-Length: ${Buffer.byteLength(body)}\r\n\r
2986
3235
  this.ttsActiveControllers.delete(streamSid);
2987
3236
  this.ttsPlaying.delete(streamSid);
2988
3237
  this.ttsQueues.delete(streamSid);
3238
+ this.invalidatePlaybackMarks(streamSid);
3239
+ this.ignoredPlaybackMarks.delete(streamSid);
2989
3240
  }
2990
3241
  resolveQueuedTtsEntries(queue) {
2991
3242
  const pending = queue.splice(0);
@@ -3052,7 +3303,9 @@ function startStaleCallReaper(params) {
3052
3303
  if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
3053
3304
  callsBeingReaped.add(call.callId);
3054
3305
  console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
3055
- 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) => {
3056
3309
  console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
3057
3310
  }).finally(() => {
3058
3311
  callsBeingReaped.delete(call.callId);
@@ -3065,13 +3318,51 @@ function startStaleCallReaper(params) {
3065
3318
  };
3066
3319
  }
3067
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
3068
3360
  //#region extensions/voice-call/src/webhook.ts
3069
3361
  const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
3070
3362
  const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
3071
3363
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
3072
- const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
3073
3364
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
3074
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-D-7ApVfT.js"));
3365
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-tjyX4tDq.js"));
3075
3366
  function appendRecentTalkEventMetadata(call, event) {
3076
3367
  const metadata = call.metadata ?? {};
3077
3368
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -3147,10 +3438,10 @@ var VoiceCallWebhookServer = class {
3147
3438
  this.server = null;
3148
3439
  this.listeningUrl = null;
3149
3440
  this.startPromise = null;
3441
+ this.stopPromise = null;
3150
3442
  this.stopStaleCallReaper = null;
3151
3443
  this.webhookInFlightLimiter = createWebhookInFlightLimiter();
3152
3444
  this.mediaStreamHandler = null;
3153
- this.pendingDisconnectHangups = /* @__PURE__ */ new Map();
3154
3445
  this.realtimeHandler = null;
3155
3446
  this.replayResponses = /* @__PURE__ */ new Map();
3156
3447
  this.replayResponseCacheCalls = 0;
@@ -3174,6 +3465,16 @@ var VoiceCallWebhookServer = class {
3174
3465
  error: (msg) => rawLogger.error(`[voice-call] ${msg}`),
3175
3466
  debug: rawDebug ? (msg) => rawDebug(`[voice-call] ${msg}`) : void 0
3176
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
+ });
3177
3478
  }
3178
3479
  /**
3179
3480
  * Get the media stream handler (for wiring to provider).
@@ -3194,11 +3495,8 @@ var VoiceCallWebhookServer = class {
3194
3495
  setRealtimeHandler(handler) {
3195
3496
  this.realtimeHandler = handler;
3196
3497
  }
3197
- clearPendingDisconnectHangup(providerCallId) {
3198
- const existing = this.pendingDisconnectHangups.get(providerCallId);
3199
- if (!existing) return;
3200
- clearTimeout(existing);
3201
- this.pendingDisconnectHangups.delete(providerCallId);
3498
+ getStreamDisconnectLifecycle() {
3499
+ return this.streamDisconnectGrace;
3202
3500
  }
3203
3501
  resolveMediaStreamClientIp(request) {
3204
3502
  const remoteIp = request.socket.remoteAddress ?? void 0;
@@ -3312,7 +3610,7 @@ var VoiceCallWebhookServer = class {
3312
3610
  },
3313
3611
  onConnect: (callId, streamSid) => {
3314
3612
  this.logger.info(`Media stream connected: ${callId} -> ${streamSid}`);
3315
- this.clearPendingDisconnectHangup(callId);
3613
+ this.streamDisconnectGrace.connect(callId, streamSid);
3316
3614
  if (this.provider.name === "twilio") this.provider.registerCallStream(callId, streamSid);
3317
3615
  },
3318
3616
  onTranscriptionReady: (callId) => {
@@ -3323,21 +3621,7 @@ var VoiceCallWebhookServer = class {
3323
3621
  onDisconnect: (callId, streamSid) => {
3324
3622
  this.logger.info(`Media stream disconnected: ${callId} (${streamSid})`);
3325
3623
  if (this.provider.name === "twilio") this.provider.unregisterCallStream(callId, streamSid);
3326
- this.clearPendingDisconnectHangup(callId);
3327
- const timer = setTimeout(() => {
3328
- this.pendingDisconnectHangups.delete(callId);
3329
- const disconnectedCall = this.manager.getCallByProviderCallId(callId);
3330
- if (!disconnectedCall) return;
3331
- if (this.provider.name === "twilio") {
3332
- if (this.provider.hasRegisteredStream(callId)) return;
3333
- }
3334
- this.logger.info(`Auto-ending call ${disconnectedCall.callId} after stream disconnect grace`);
3335
- this.manager.endCall(disconnectedCall.callId).catch((err) => {
3336
- this.logger.warn(`Failed to auto-end call ${disconnectedCall.callId}: ${String(err)}`);
3337
- });
3338
- }, STREAM_DISCONNECT_HANGUP_GRACE_MS);
3339
- timer.unref?.();
3340
- this.pendingDisconnectHangups.set(callId, timer);
3624
+ this.streamDisconnectGrace.disconnect(callId, streamSid);
3341
3625
  }
3342
3626
  };
3343
3627
  this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
@@ -3348,6 +3632,7 @@ var VoiceCallWebhookServer = class {
3348
3632
  * Idempotent: returns immediately if the server is already listening.
3349
3633
  */
3350
3634
  async start() {
3635
+ if (this.stopPromise) await this.stopPromise;
3351
3636
  const { port, bind, path: webhookPath } = this.config.serve;
3352
3637
  const streamPath = this.config.streaming.streamPath;
3353
3638
  if (this.server?.listening) return this.listeningUrl ?? this.resolveListeningUrl(bind, webhookPath);
@@ -3397,26 +3682,44 @@ var VoiceCallWebhookServer = class {
3397
3682
  /**
3398
3683
  * Stop the webhook server.
3399
3684
  */
3400
- async stop() {
3401
- for (const timer of this.pendingDisconnectHangups.values()) clearTimeout(timer);
3402
- this.pendingDisconnectHangups.clear();
3403
- this.webhookInFlightLimiter.clear();
3685
+ stop() {
3686
+ if (this.stopPromise) return this.stopPromise;
3687
+ const server = this.server;
3688
+ const serverClosePromise = new Promise((resolve, reject) => {
3689
+ if (!server) {
3690
+ resolve();
3691
+ return;
3692
+ }
3693
+ server.close((error) => {
3694
+ if (error) {
3695
+ reject(error);
3696
+ return;
3697
+ }
3698
+ resolve();
3699
+ });
3700
+ });
3404
3701
  this.startPromise = null;
3702
+ this.streamDisconnectGrace.close();
3405
3703
  if (this.stopStaleCallReaper) {
3406
3704
  this.stopStaleCallReaper();
3407
3705
  this.stopStaleCallReaper = null;
3408
3706
  }
3409
- return new Promise((resolve) => {
3410
- if (this.server) this.server.close(() => {
3411
- this.server = null;
3412
- this.listeningUrl = null;
3413
- resolve();
3414
- });
3415
- else {
3416
- this.listeningUrl = null;
3417
- resolve();
3418
- }
3707
+ this.webhookInFlightLimiter.clear();
3708
+ this.stopPromise = (async () => {
3709
+ const results = await Promise.allSettled([
3710
+ serverClosePromise,
3711
+ this.mediaStreamHandler?.close(serverClosePromise) ?? Promise.resolve(),
3712
+ this.realtimeHandler?.close(serverClosePromise) ?? Promise.resolve()
3713
+ ]);
3714
+ this.streamDisconnectGrace.close();
3715
+ if (this.server === server) this.server = null;
3716
+ this.listeningUrl = null;
3717
+ const failure = results.find((result) => result.status === "rejected");
3718
+ if (failure) throw failure.reason;
3719
+ })().finally(() => {
3720
+ this.stopPromise = null;
3419
3721
  });
3722
+ return this.stopPromise;
3420
3723
  }
3421
3724
  resolveListeningUrl(bind, webhookPath) {
3422
3725
  const address = this.server?.address();
@@ -3521,9 +3824,9 @@ var VoiceCallWebhookServer = class {
3521
3824
  const isReplay = Boolean(verification.isReplay);
3522
3825
  if (isReplay) {
3523
3826
  this.logger.warn("Replay detected; skipping event side effects");
3524
- if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
3525
3827
  const cachedResponse = await this.getCachedReplayResponse(verification.verifiedRequestKey);
3526
3828
  if (cachedResponse) return cachedResponse;
3829
+ if (this.provider.name === "twilio") return buildTwilioReplayTwiML();
3527
3830
  }
3528
3831
  const buildResponse = async () => {
3529
3832
  const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);
@@ -3547,12 +3850,11 @@ var VoiceCallWebhookServer = class {
3547
3850
  return this.realtimeHandler.buildTwiMLPayload(req, realtimeParams);
3548
3851
  }
3549
3852
  const parsed = this.provider.parseWebhookEvent(ctx, { verifiedRequestKey: verification.verifiedRequestKey });
3550
- if (!isReplay) this.processParsedEvents(parsed.events);
3853
+ if (!isReplay && this.processParsedEvents(parsed.events)) verification.releaseReplay?.();
3551
3854
  return normalizeWebhookResponse(parsed);
3552
3855
  };
3553
3856
  if (isReplay) return await buildResponse();
3554
- if (this.provider.name === "twilio") return await buildResponse();
3555
- return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse);
3857
+ return await this.cacheReplayResponse(verification.verifiedRequestKey, buildResponse, verification.releaseReplay);
3556
3858
  } finally {
3557
3859
  this.webhookInFlightLimiter.release(inFlightKey);
3558
3860
  }
@@ -3578,21 +3880,28 @@ var VoiceCallWebhookServer = class {
3578
3880
  }
3579
3881
  return cloneWebhookResponsePayload(await entry.response);
3580
3882
  }
3581
- async cacheReplayResponse(key, buildResponse) {
3883
+ async cacheReplayResponse(key, buildResponse, releaseReplay) {
3582
3884
  const now = Date.now();
3583
3885
  const expiresAt = resolveExpiresAtMsFromDurationMs(WEBHOOK_REPLAY_RESPONSE_TTL_MS, { nowMs: now });
3584
3886
  this.replayResponseCacheCalls += 1;
3585
3887
  if (this.replayResponseCacheCalls % WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL === 0) this.pruneReplayResponses(now);
3586
- const response = buildResponse().then(cloneWebhookResponsePayload).catch((err) => {
3587
- this.replayResponses.delete(key);
3888
+ let cachedEntry;
3889
+ const ownerResponse = buildResponse().then(cloneWebhookResponsePayload).catch((err) => {
3890
+ if (cachedEntry && this.replayResponses.get(key) === cachedEntry) this.replayResponses.delete(key);
3891
+ releaseReplay?.();
3588
3892
  throw err;
3589
3893
  });
3590
- if (expiresAt !== void 0) this.replayResponses.set(key, {
3591
- expiresAt,
3592
- response
3593
- });
3894
+ const response = ownerResponse.then((payload) => this.provider.name === "twilio" ? buildTwilioReplayTwiML() : cloneWebhookResponsePayload(payload));
3895
+ response.catch(() => {});
3896
+ if (expiresAt !== void 0) {
3897
+ cachedEntry = {
3898
+ expiresAt,
3899
+ response
3900
+ };
3901
+ this.replayResponses.set(key, cachedEntry);
3902
+ }
3594
3903
  if (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) this.pruneReplayResponses(now);
3595
- return cloneWebhookResponsePayload(await response);
3904
+ return cloneWebhookResponsePayload(await ownerResponse);
3596
3905
  }
3597
3906
  verifyPreAuthWebhookHeaders(headers) {
3598
3907
  if (this.config.skipSignatureVerification) return { ok: true };
@@ -3656,20 +3965,25 @@ var VoiceCallWebhookServer = class {
3656
3965
  }
3657
3966
  }
3658
3967
  processParsedEvents(events) {
3968
+ let replayable = false;
3659
3969
  for (const event of events) try {
3660
- this.processEventWithAutoResponse(event);
3970
+ replayable = this.processEventWithAutoResponse(event) || replayable;
3661
3971
  } catch (err) {
3662
3972
  this.logger.error(`Error processing event ${event.type}: ${String(err)}`);
3973
+ throw err;
3663
3974
  }
3975
+ return replayable;
3664
3976
  }
3665
3977
  processEventWithAutoResponse(event) {
3666
3978
  const result = this.manager.processEvent(event);
3667
- if (result.kind !== "final-speech" || result.waiterResolved) return;
3979
+ if (result.kind !== "final-speech") return result.replayable === true;
3980
+ if (result.waiterResolved) return false;
3668
3981
  const callMode = result.call.metadata?.mode;
3669
- if (result.call.direction !== "inbound" && callMode !== "conversation") return;
3982
+ if (result.call.direction !== "inbound" && callMode !== "conversation") return false;
3670
3983
  this.handleInboundResponse(result.call.callId, result.transcript).catch((err) => {
3671
3984
  this.logger.warn(`Failed to auto-respond: ${String(err)}`);
3672
3985
  });
3986
+ return false;
3673
3987
  }
3674
3988
  writeWebhookResponse(res, payload) {
3675
3989
  res.statusCode = payload.statusCode;
@@ -3715,6 +4029,7 @@ var VoiceCallWebhookServer = class {
3715
4029
  callId,
3716
4030
  sessionKey: call.sessionKey,
3717
4031
  from: call.from,
4032
+ senderIsOwner: call.direction === "inbound" ? false : void 0,
3718
4033
  agentId: resolveCallAgentId(call, effectiveConfig),
3719
4034
  transcript: call.transcript,
3720
4035
  userMessage,
@@ -3746,12 +4061,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3746
4061
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3747
4062
  "Be accurate, brief, and speakable."
3748
4063
  ].join(" ");
3749
- const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-Dx6cfLvV.js"));
3750
- const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-D_D8KysU.js"));
3751
- const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-vZ--Wqxv.js"));
3752
- const loadMockProvider = createLazyRuntimeModule(() => import("./mock-YsvBTX-7.js"));
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"));
3753
4068
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
3754
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler--i_OERv6.js"));
4069
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-BaMEKbmJ.js"));
3755
4070
  function resolveVoiceCallConsultSessionKey(call) {
3756
4071
  return resolveVoiceCallSessionKey({
3757
4072
  config: call.config,
@@ -3775,7 +4090,7 @@ function mapVoiceCallConsultTranscript(call, context) {
3775
4090
  }
3776
4091
  function createRuntimeResourceLifecycle(params) {
3777
4092
  let tunnelResult = null;
3778
- let stopped = false;
4093
+ let stopPromise = null;
3779
4094
  const runStep = async (step, suppressErrors) => {
3780
4095
  if (suppressErrors) {
3781
4096
  await step().catch(() => {});
@@ -3787,19 +4102,21 @@ function createRuntimeResourceLifecycle(params) {
3787
4102
  setTunnelResult: (result) => {
3788
4103
  tunnelResult = result;
3789
4104
  },
3790
- stop: async (opts) => {
3791
- if (stopped) return;
3792
- stopped = true;
4105
+ stop: (opts) => {
4106
+ if (stopPromise) return stopPromise;
3793
4107
  const suppressErrors = opts?.suppressErrors ?? false;
3794
- await runStep(async () => {
3795
- if (tunnelResult) await tunnelResult.stop();
3796
- }, suppressErrors);
3797
- await runStep(async () => {
3798
- await cleanupTailscaleExposure(params.config);
3799
- }, suppressErrors);
3800
- await runStep(async () => {
3801
- await params.webhookServer.stop();
3802
- }, suppressErrors);
4108
+ stopPromise = (async () => {
4109
+ await runStep(async () => {
4110
+ if (tunnelResult) await tunnelResult.stop();
4111
+ }, suppressErrors);
4112
+ await runStep(async () => {
4113
+ await cleanupTailscaleExposure(params.config);
4114
+ }, suppressErrors);
4115
+ await runStep(async () => {
4116
+ await params.webhookServer.stop();
4117
+ }, suppressErrors);
4118
+ })();
4119
+ return stopPromise;
3803
4120
  }
3804
4121
  };
3805
4122
  }
@@ -3847,17 +4164,9 @@ async function resolveProvider(config) {
3847
4164
  default: throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
3848
4165
  }
3849
4166
  }
3850
- async function resolveRealtimeProvider(params) {
3851
- const { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
3852
- return resolveConfiguredRealtimeVoiceProvider({
3853
- configuredProviderId: params.config.realtime.provider,
3854
- providerConfigs: params.config.realtime.providers,
3855
- cfg: params.fullConfig
3856
- });
3857
- }
3858
4167
  function listRealtimeAgentIds(config, coreConfig) {
3859
4168
  const agentIds = /* @__PURE__ */ new Set([normalizeAgentId(config.agentId)]);
3860
- for (const agent of coreConfig.agents?.list ?? []) agentIds.add(normalizeAgentId(agent.id));
4169
+ for (const agentId of listAgentIds(coreConfig)) agentIds.add(normalizeAgentId(agentId));
3861
4170
  for (const route of Object.values(config.numbers)) if (route.agentId) agentIds.add(normalizeAgentId(route.agentId));
3862
4171
  return [...agentIds];
3863
4172
  }
@@ -3920,23 +4229,39 @@ async function createVoiceCallRuntime(params) {
3920
4229
  const provider = await resolveProvider(config);
3921
4230
  if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
3922
4231
  const manager = new CallManager(config, void 0, cfg.session);
3923
- const realtimeProvider = config.realtime.enabled ? await resolveRealtimeProvider({
3924
- config,
3925
- fullConfig: cfg
3926
- }) : null;
4232
+ const realtimeVoiceRuntime = config.realtime.enabled ? await loadRealtimeVoiceRuntime() : null;
3927
4233
  const webhookServer = new VoiceCallWebhookServer(config, manager, provider, coreConfig, fullConfig ?? coreConfig, agentRuntime, log);
3928
- if (realtimeProvider) {
4234
+ if (realtimeVoiceRuntime) {
3929
4235
  const { RealtimeCallHandler } = await loadRealtimeHandler();
3930
4236
  const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
3931
4237
  config,
3932
4238
  coreConfig: cfg,
3933
4239
  agentRuntime
3934
4240
  });
3935
- const realtimeHandler = new RealtimeCallHandler({
4241
+ const realtimeConfig = {
3936
4242
  ...config.realtime,
3937
- tools: resolveRealtimeVoiceAgentConsultTools(config.realtime.toolPolicy, config.realtime.tools)
3938
- }, 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);
3939
4263
  if (config.realtime.toolPolicy !== "none") realtimeHandler.registerToolHandler(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, async (args, callId, handlerContext) => {
4264
+ handlerContext.abortSignal?.throwIfAborted();
3940
4265
  const call = manager.getCall(callId);
3941
4266
  if (!call) return { error: `Call "${callId}" not found` };
3942
4267
  const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
@@ -3967,6 +4292,7 @@ async function createVoiceCallRuntime(params) {
3967
4292
  args,
3968
4293
  logger: log
3969
4294
  });
4295
+ handlerContext.abortSignal?.throwIfAborted();
3970
4296
  if (fastContext.handled) {
3971
4297
  assertRealtimeVoiceAgentConsultModelSelectionUnlocked(modelLockParams);
3972
4298
  return fastContext.result;
@@ -4003,7 +4329,8 @@ async function createVoiceCallRuntime(params) {
4003
4329
  spawnedBy: requesterSessionKey,
4004
4330
  contextMode: requesterSessionKey ? "fork" : void 0,
4005
4331
  toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(effectiveConfig.realtime.toolPolicy),
4006
- extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT
4332
+ extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
4333
+ abortSignal: handlerContext.abortSignal
4007
4334
  });
4008
4335
  });
4009
4336
  webhookServer.setRealtimeHandler(realtimeHandler);
@@ -4019,7 +4346,12 @@ async function createVoiceCallRuntime(params) {
4019
4346
  const nextTunnelResult = await startTunnel({
4020
4347
  provider: config.tunnel.provider,
4021
4348
  port: config.serve.port,
4349
+ tailscalePort: config.tailscale.port,
4022
4350
  path: config.serve.path,
4351
+ streamPaths: resolveVoiceCallStreamExposurePaths(config, {
4352
+ publicWebhookPath: config.serve.path,
4353
+ localWebhookPath: config.serve.path
4354
+ }),
4023
4355
  ngrokAuthToken: config.tunnel.ngrokAuthToken,
4024
4356
  ngrokDomain: config.tunnel.ngrokDomain
4025
4357
  });
@@ -4032,7 +4364,7 @@ async function createVoiceCallRuntime(params) {
4032
4364
  const webhookUrl = publicUrl ?? localUrl;
4033
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.`);
4034
4366
  if (publicUrl) provider.setPublicUrl?.(publicUrl);
4035
- if (publicUrl && realtimeProvider) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
4367
+ if (publicUrl && realtimeVoiceRuntime) webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
4036
4368
  const realtimeHandler = webhookServer.getRealtimeHandler();
4037
4369
  if (realtimeHandler) manager.streamSessionIssuer = (request) => realtimeHandler.issueStreamSession(request);
4038
4370
  if (provider.name === "twilio" && config.streaming?.enabled) {
@@ -4056,9 +4388,9 @@ async function createVoiceCallRuntime(params) {
4056
4388
  log.info("[voice-call] Media stream handler wired to provider");
4057
4389
  }
4058
4390
  }
4059
- if (realtimeProvider) log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
4391
+ if (realtimeVoiceRuntime) log.info("[voice-call] Realtime voice enabled");
4060
4392
  await manager.initialize(provider, webhookUrl);
4061
- const stop = async () => await lifecycle.stop();
4393
+ const stop = () => lifecycle.stop();
4062
4394
  log.info("[voice-call] Runtime initialized");
4063
4395
  log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
4064
4396
  if (publicUrl && publicUrl !== webhookUrl) log.info(`[voice-call] Public URL: ${publicUrl}`);
@@ -4077,4 +4409,4 @@ async function createVoiceCallRuntime(params) {
4077
4409
  }
4078
4410
  }
4079
4411
  //#endregion
4080
- 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 };