@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.
package/dist/index.js CHANGED
@@ -1,40 +1,183 @@
1
1
  import { definePluginEntry, sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
4
- import { S as validateProviderConfig, b as resolveVoiceCallConfig, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoute, g as resolveUserPath, l as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime, u as getTailscaleSelfInfo, y as VoiceCallConfigSchema } from "./runtime-entry-D0UJiLfi.js";
5
- import { g as setVoiceCallStateRuntime, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-nYL-yM0S.js";
4
+ import { C as resolveVoiceCallStreamExposurePaths, b as resolveVoiceCallConfig, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoutes, g as resolveUserPath, l as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime, u as getTailscaleSelfInfo, w as validateProviderConfig, y as VoiceCallConfigSchema } from "./runtime-entry-DK8kPCGC.js";
5
+ import { b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, l as findCallMatchesInStore, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-51_oC_xn.js";
6
6
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7
- import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
8
- import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
9
- import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
10
- import { asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
7
+ import { ErrorCodes, callGatewayFromCli, errorShape, isGatewayClientRequestError, isGatewayTransportError, redactSensitiveUrlLikeString } from "openclaw/plugin-sdk/gateway-runtime";
8
+ import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
9
+ import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
10
+ import { asNonArrayRecord, asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
11
11
  import { jsonResult } from "openclaw/plugin-sdk/tool-results";
12
12
  import { Type } from "typebox";
13
+ import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
13
14
  import fs from "node:fs";
14
15
  import { randomUUID } from "node:crypto";
15
16
  import path from "node:path";
16
17
  import { StringDecoder } from "node:string_decoder";
17
18
  import { format } from "node:util";
18
- //#region extensions/voice-call/src/cli.ts
19
- const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5e3;
20
- const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
21
- const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
22
- const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
23
- function writeStdoutLine(...values) {
19
+ //#region extensions/voice-call/src/cli-command-io.ts
20
+ function writeCliLine(...values) {
24
21
  process.stdout.write(`${format(...values)}\n`);
25
22
  }
26
- function writeStdoutJson(value) {
23
+ function writeCliJson(value) {
27
24
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
28
25
  }
29
- function parseVoiceCallIntOption(raw, optionName, opts) {
26
+ function parseCliInteger(raw, optionName, opts) {
30
27
  const min = opts?.min ?? 0;
31
28
  const parsed = parseStrictNonNegativeInteger(raw?.trim() ?? "");
32
29
  if (parsed === void 0 || parsed < min || opts?.max !== void 0 && parsed > opts.max) throw new Error(`Invalid numeric value for ${optionName}: ${raw ?? ""}`);
33
30
  return parsed;
34
31
  }
32
+ //#endregion
33
+ //#region extensions/voice-call/src/cli-call-log.ts
34
+ function percentile(values, p) {
35
+ if (values.length === 0) return 0;
36
+ const sorted = [...values].toSorted((a, b) => a - b);
37
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))] ?? 0;
38
+ }
39
+ function summarizeSeries(values) {
40
+ if (values.length === 0) return {
41
+ count: 0,
42
+ minMs: 0,
43
+ maxMs: 0,
44
+ avgMs: 0,
45
+ p50Ms: 0,
46
+ p95Ms: 0
47
+ };
48
+ const minMs = values.reduce((min, value) => value < min ? value : min);
49
+ const maxMs = values.reduce((max, value) => value > max ? value : max);
50
+ const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
51
+ return {
52
+ count: values.length,
53
+ minMs,
54
+ maxMs,
55
+ avgMs,
56
+ p50Ms: percentile(values, 50),
57
+ p95Ms: percentile(values, 95)
58
+ };
59
+ }
60
+ function writeVoiceCallLatencySummary(calls) {
61
+ const turnLatencyMs = [];
62
+ const listenWaitMs = [];
63
+ for (const call of calls) {
64
+ const metadata = isRecord(call) && isRecord(call.metadata) ? call.metadata : void 0;
65
+ const latency = metadata?.lastTurnLatencyMs;
66
+ const listenWait = metadata?.lastTurnListenWaitMs;
67
+ if (typeof latency === "number" && Number.isFinite(latency)) turnLatencyMs.push(latency);
68
+ if (typeof listenWait === "number" && Number.isFinite(listenWait)) listenWaitMs.push(listenWait);
69
+ }
70
+ writeCliJson({
71
+ recordsScanned: calls.length,
72
+ turnLatency: summarizeSeries(turnLatencyMs),
73
+ listenWait: summarizeSeries(listenWaitMs)
74
+ });
75
+ }
76
+ function registerVoiceCallLogs(params) {
77
+ params.root.command("tail").description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)").option("--file <path>", "Path to calls.jsonl", params.defaultFile).option("--since <n>", "Print last N lines first", "25").option("--poll <ms>", "Poll interval in ms", "250").action(async (options) => {
78
+ const file = options.file;
79
+ const since = parseCliInteger(options.since, "--since", { min: 0 });
80
+ const pollMs = parseCliInteger(options.poll, "--poll", { min: 50 });
81
+ const tailSqliteHistory = async (initialLimit) => {
82
+ params.ensureHistoryStateRuntime();
83
+ const seen = /* @__PURE__ */ new Set();
84
+ const printCall = (call) => {
85
+ const line = JSON.stringify(call);
86
+ if (!seen.has(line)) {
87
+ seen.add(line);
88
+ writeCliLine(line);
89
+ }
90
+ };
91
+ if (initialLimit > 0) for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) printCall(call);
92
+ for (;;) {
93
+ try {
94
+ for (const call of await getCallHistoryFromStore(path.dirname(file), 1e3)) printCall(call);
95
+ } catch {}
96
+ await sleep(pollMs);
97
+ }
98
+ };
99
+ if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
100
+ const initial = fs.readFileSync(file);
101
+ let decoder = new StringDecoder("utf8");
102
+ const initialLines = decoder.write(initial).split("\n");
103
+ let pendingLine = initialLines.pop() ?? "";
104
+ const lines = initialLines.filter(Boolean);
105
+ for (const line of lines.slice(Math.max(0, lines.length - since))) writeCliLine(line);
106
+ let offset = initial.length;
107
+ let lastObservedSize = initial.length;
108
+ for (;;) {
109
+ try {
110
+ const stat = fs.statSync(file);
111
+ if (stat.size < lastObservedSize) {
112
+ offset = 0;
113
+ decoder = new StringDecoder("utf8");
114
+ pendingLine = "";
115
+ }
116
+ lastObservedSize = stat.size;
117
+ if (stat.size > offset) {
118
+ const fd = fs.openSync(file, "r");
119
+ try {
120
+ const buf = Buffer.alloc(stat.size - offset);
121
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
122
+ offset += bytesRead;
123
+ const text = decoder.write(buf.subarray(0, bytesRead));
124
+ const completeLines = `${pendingLine}${text}`.split("\n");
125
+ pendingLine = completeLines.pop() ?? "";
126
+ for (const line of completeLines.filter(Boolean)) writeCliLine(line);
127
+ } finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ }
131
+ } catch {}
132
+ await sleep(pollMs);
133
+ }
134
+ } else await tailSqliteHistory(since);
135
+ });
136
+ params.root.command("latency").description("Summarize turn latency metrics from voice-call JSONL logs").option("--file <path>", "Path to calls.jsonl", params.defaultFile).option("--last <n>", "Analyze last N records", "200").action(async (options) => {
137
+ const file = options.file;
138
+ const last = parseCliInteger(options.last, "--last", { min: 1 });
139
+ if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") writeVoiceCallLatencySummary(fs.readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-last).map((line) => {
140
+ try {
141
+ const parsed = JSON.parse(line);
142
+ return (isRecord(parsed) ? parsed.call : void 0) ?? parsed;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }).filter((call) => call !== null));
147
+ else {
148
+ params.ensureHistoryStateRuntime();
149
+ writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
150
+ }
151
+ });
152
+ }
153
+ //#endregion
154
+ //#region extensions/voice-call/src/cli-gateway-call.ts
155
+ const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5e3;
156
+ const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
157
+ const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
158
+ const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
35
159
  function isGatewayUnavailableForLocalFallback(err) {
160
+ return isGatewayTransportError(err) && err.kind === "closed" && (err.code === void 0 || err.code === 1006);
161
+ }
162
+ function isGatewayCredentialFailure(err) {
163
+ return err instanceof Error && (err.name === "GatewayCredentialsRequiredError" || err.name === "GatewayExplicitAuthRequiredError" || err.name === "GatewaySecretRefUnavailableError");
164
+ }
165
+ function gatewayOperationalError(err) {
36
166
  const message = formatErrorMessage(err);
37
- return message.includes("ECONNREFUSED") || message.includes("ECONNRESET") || message.includes("EHOSTUNREACH") || message.includes("ENOTFOUND") || message.includes("gateway closed (1006") || message.includes("gateway not connected");
167
+ const detail = (() => {
168
+ if (isGatewayClientRequestError(err)) return `Gateway responded but voicecall failed: ${message}\nThe running Gateway owns the voice-call runtime; check \`openclaw gateway status\` or restart it.`;
169
+ if (isGatewayCredentialFailure(err)) return `Gateway requires credentials: ${message}\nConfigure gateway.auth or pair this device with \`openclaw devices approve --latest\`.`;
170
+ if (isGatewayTransportError(err)) {
171
+ const url = err.connectionDetails.url;
172
+ if (err.kind === "timeout") return `Gateway at ${url} did not answer within ${err.timeoutMs === void 0 ? "the configured timeout" : `${err.timeoutMs}ms`}: ${message}\nIt may be starting or wedged; check \`openclaw gateway status\`.`;
173
+ return `Gateway connection at ${url} failed: ${message}\nCheck gateway.auth and \`openclaw gateway status\`, then retry.`;
174
+ }
175
+ return `Gateway voicecall request failed: ${message}\nCheck \`openclaw gateway status\`, then retry.`;
176
+ })();
177
+ return new Error(redactSensitiveUrlLikeString(detail));
178
+ }
179
+ function isUnknownMethod(err, method) {
180
+ return formatErrorMessage(err).includes(`unknown method: ${method}`);
38
181
  }
39
182
  async function callVoiceCallGateway(method, params, opts) {
40
183
  try {
@@ -51,21 +194,18 @@ async function callVoiceCallGateway(method, params, opts) {
51
194
  ok: false,
52
195
  error: err
53
196
  };
54
- throw err;
197
+ throw gatewayOperationalError(err);
55
198
  }
56
199
  }
57
- function resolveGatewayOperationTimeoutMs(config) {
200
+ function resolveOperationTimeout(config) {
58
201
  return Math.max(VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS, addTimerTimeoutGraceMs(config.ringTimeoutMs) ?? 1);
59
202
  }
60
- function resolveGatewayContinueTimeoutMs(config) {
203
+ function resolveContinueTimeout(config) {
61
204
  return clampTimerTimeoutMs(config.transcriptTimeoutMs + VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS + VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS) ?? 1;
62
205
  }
63
206
  function resolveVoiceCallDeadlineMs(timeoutMs, nowMs = Date.now()) {
64
207
  return nowMs + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
65
208
  }
66
- function isUnknownGatewayMethod(err, method) {
67
- return formatErrorMessage(err).includes(`unknown method: ${method}`);
68
- }
69
209
  function readGatewayOperationId(payload) {
70
210
  if (isRecord(payload) && typeof payload.operationId === "string" && payload.operationId) return payload.operationId;
71
211
  throw new Error("voicecall gateway response missing operationId");
@@ -87,7 +227,12 @@ function readCompletedContinueResult(payload) {
87
227
  };
88
228
  throw new Error("voicecall gateway response has unknown operation status");
89
229
  }
90
- async function pollVoiceCallContinueGateway(params) {
230
+ async function pollContinueGateway(payload, fallbackTimeoutMs) {
231
+ if (!isRecord(payload) || typeof payload.operationId !== "string") return payload;
232
+ const params = {
233
+ operationId: readGatewayOperationId(payload),
234
+ timeoutMs: readGatewayPollTimeoutMs(payload, fallbackTimeoutMs)
235
+ };
91
236
  const deadlineMs = resolveVoiceCallDeadlineMs(params.timeoutMs);
92
237
  for (;;) {
93
238
  const remainingMs = deadlineMs - Date.now();
@@ -103,6 +248,54 @@ async function pollVoiceCallContinueGateway(params) {
103
248
  }
104
249
  throw new Error("voicecall continue timed out waiting for gateway operation");
105
250
  }
251
+ async function ensureStandaloneRuntime(params) {
252
+ try {
253
+ return await params.ensureRuntime();
254
+ } catch (err) {
255
+ if (err instanceof Error && "code" in err && err.code === "EADDRINUSE") throw new Error(`Voice-call webhook port ${params.config.serve.port} is already in use. A running Gateway probably already serves it; operational commands route through that Gateway. Check \`openclaw gateway status\` and retry.`, { cause: err });
256
+ throw err;
257
+ }
258
+ }
259
+ async function runGatewayManagerCommand(params) {
260
+ const gateway = await params.gatewayCall();
261
+ if (gateway.ok) {
262
+ writeCliJson(params.resolveGatewayPayload ? await params.resolveGatewayPayload(gateway.payload) : gateway.payload);
263
+ return;
264
+ }
265
+ const runtime = await ensureStandaloneRuntime(params);
266
+ const result = await params.managerFallback(runtime.manager);
267
+ if (!result.success) throw new Error(result.error || `${params.failureLabel} failed`);
268
+ writeCliJson(result);
269
+ }
270
+ function readGatewayCallId(payload, invalidCallIdMessage) {
271
+ if (isRecord(payload) && typeof payload.callId === "string") {
272
+ if (!invalidCallIdMessage || payload.callId) return payload.callId;
273
+ }
274
+ if (invalidCallIdMessage) throw new Error(invalidCallIdMessage);
275
+ if (isRecord(payload) && typeof payload.error === "string") throw new Error(payload.error);
276
+ throw new Error("voicecall gateway response missing callId");
277
+ }
278
+ async function initiateVoiceCall(params) {
279
+ const mode = params.mode === "notify" || params.mode === "conversation" ? params.mode : params.defaultMode;
280
+ const gateway = await callVoiceCallGateway(params.method, {
281
+ ...params.to ? { to: params.to } : {},
282
+ ...params.message ? { message: params.message } : {},
283
+ ...mode ? { mode } : {}
284
+ }, { timeoutMs: resolveOperationTimeout(params.config) });
285
+ if (gateway.ok) return readGatewayCallId(gateway.payload, params.failureMessage);
286
+ const runtime = await ensureStandaloneRuntime(params);
287
+ const to = params.to ?? runtime.config.toNumber;
288
+ if (!to) throw new Error("Missing --to and no toNumber configured");
289
+ const result = await runtime.manager.initiateCall(to, void 0, {
290
+ message: params.message,
291
+ mode
292
+ });
293
+ if (!result.success) throw new Error(result.error || params.failureMessage || "initiate failed");
294
+ if (params.failureMessage && !result.callId) throw new Error(params.failureMessage);
295
+ return result.callId;
296
+ }
297
+ //#endregion
298
+ //#region extensions/voice-call/src/cli.ts
106
299
  function resolveMode(input) {
107
300
  const raw = normalizeOptionalLowercaseString(input) ?? "";
108
301
  if (raw === "serve" || raw === "off") return raw;
@@ -112,35 +305,6 @@ function resolveDefaultStorePath(config) {
112
305
  const base = config.store?.trim() ? resolveUserPath(config.store) : resolveDefaultVoiceCallStoreDir();
113
306
  return path.join(base, "calls.jsonl");
114
307
  }
115
- function percentile(values, p) {
116
- if (values.length === 0) return 0;
117
- const sorted = [...values].toSorted((a, b) => a - b);
118
- return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))] ?? 0;
119
- }
120
- function summarizeSeries(values) {
121
- if (values.length === 0) return {
122
- count: 0,
123
- minMs: 0,
124
- maxMs: 0,
125
- avgMs: 0,
126
- p50Ms: 0,
127
- p95Ms: 0
128
- };
129
- const minMs = values.reduce((min, value) => value < min ? value : min, Number.POSITIVE_INFINITY);
130
- const maxMs = values.reduce((max, value) => value > max ? value : max, Number.NEGATIVE_INFINITY);
131
- const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
132
- return {
133
- count: values.length,
134
- minMs,
135
- maxMs,
136
- avgMs,
137
- p50Ms: percentile(values, 50),
138
- p95Ms: percentile(values, 95)
139
- };
140
- }
141
- function resolveCallMode(mode) {
142
- return mode === "notify" || mode === "conversation" ? mode : void 0;
143
- }
144
308
  function buildSetupStatus(config) {
145
309
  const validation = validateProviderConfig(config);
146
310
  const webhookExposure = resolveWebhookExposureStatus(config);
@@ -177,45 +341,8 @@ function buildSetupStatus(config) {
177
341
  };
178
342
  }
179
343
  function writeSetupStatus(status) {
180
- writeStdoutLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
181
- for (const check of status.checks) writeStdoutLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
182
- }
183
- async function initiateCallAndPrintId(params) {
184
- const result = await params.runtime.manager.initiateCall(params.to, void 0, {
185
- message: params.message,
186
- mode: resolveCallMode(params.mode)
187
- });
188
- if (!result.success) throw new Error(result.error || "initiate failed");
189
- writeStdoutJson({ callId: result.callId });
190
- }
191
- function writeGatewayCallId(payload) {
192
- if (isRecord(payload) && typeof payload.callId === "string") {
193
- writeStdoutJson({ callId: payload.callId });
194
- return;
195
- }
196
- if (isRecord(payload) && typeof payload.error === "string") throw new Error(payload.error);
197
- throw new Error("voicecall gateway response missing callId");
198
- }
199
- async function initiateCallViaGatewayOrRuntime(params) {
200
- const mode = resolveCallMode(params.mode);
201
- const gateway = await callVoiceCallGateway(params.method, {
202
- ...params.to ? { to: params.to } : {},
203
- ...params.message ? { message: params.message } : {},
204
- ...mode ? { mode } : {}
205
- }, { timeoutMs: resolveGatewayOperationTimeoutMs(params.config) });
206
- if (gateway.ok) {
207
- writeGatewayCallId(gateway.payload);
208
- return;
209
- }
210
- const rt = await params.ensureRuntime();
211
- const to = params.to ?? rt.config.toNumber;
212
- if (!to) throw new Error("Missing --to and no toNumber configured");
213
- await initiateCallAndPrintId({
214
- runtime: rt,
215
- to,
216
- message: params.message,
217
- mode: params.mode
218
- });
344
+ writeCliLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
345
+ for (const check of status.checks) writeCliLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
219
346
  }
220
347
  function registerVoiceCallCli(params) {
221
348
  const { program, config, ensureRuntime, stateRuntime } = params;
@@ -226,7 +353,7 @@ function registerVoiceCallCli(params) {
226
353
  root.command("setup").description("Show Voice Call provider and webhook setup status").option("--json", "Print machine-readable JSON").action((options) => {
227
354
  const status = buildSetupStatus(config);
228
355
  if (options.json) {
229
- writeStdoutJson(status);
356
+ writeCliJson(status);
230
357
  return;
231
358
  }
232
359
  writeSetupStatus(status);
@@ -234,7 +361,7 @@ function registerVoiceCallCli(params) {
234
361
  root.command("smoke").description("Check Voice Call readiness and optionally place a short outbound test call").option("-t, --to <phone>", "Phone number to call for a live smoke").option("--message <text>", "Message to speak during the smoke call", "OpenClaw voice call smoke test.").option("--mode <mode>", "Call mode: notify or conversation", "notify").option("--yes", "Actually place the live outbound call").option("--json", "Print machine-readable JSON").action(async (options) => {
235
362
  const setup = buildSetupStatus(config);
236
363
  if (!setup.ok) {
237
- if (options.json) writeStdoutJson({
364
+ if (options.json) writeCliJson({
238
365
  ok: false,
239
366
  setup
240
367
  });
@@ -243,19 +370,19 @@ function registerVoiceCallCli(params) {
243
370
  return;
244
371
  }
245
372
  if (!options.to) {
246
- if (options.json) writeStdoutJson({
373
+ if (options.json) writeCliJson({
247
374
  ok: true,
248
375
  setup,
249
376
  liveCall: false
250
377
  });
251
378
  else {
252
379
  writeSetupStatus(setup);
253
- writeStdoutLine("live-call: skipped (pass --to and --yes to place one)");
380
+ writeCliLine("live-call: skipped (pass --to and --yes to place one)");
254
381
  }
255
382
  return;
256
383
  }
257
384
  if (!options.yes) {
258
- if (options.json) writeStdoutJson({
385
+ if (options.json) writeCliJson({
259
386
  ok: true,
260
387
  setup,
261
388
  liveCall: false,
@@ -263,29 +390,22 @@ function registerVoiceCallCli(params) {
263
390
  });
264
391
  else {
265
392
  writeSetupStatus(setup);
266
- writeStdoutLine("live-call: dry run for %s (add --yes to place it)", options.to);
393
+ writeCliLine("live-call: dry run for %s (add --yes to place it)", options.to);
267
394
  }
268
395
  return;
269
396
  }
270
- const mode = resolveCallMode(options.mode) ?? "notify";
271
- const gateway = await callVoiceCallGateway("voicecall.start", {
397
+ const callId = await initiateVoiceCall({
398
+ ensureRuntime,
399
+ config,
400
+ method: "voicecall.start",
272
401
  to: options.to,
273
- ...options.message ? { message: options.message } : {},
274
- mode
275
- }, { timeoutMs: resolveGatewayOperationTimeoutMs(config) });
276
- let callId;
277
- if (gateway.ok) callId = isRecord(gateway.payload) ? gateway.payload.callId : void 0;
278
- else {
279
- const result = await (await ensureRuntime()).manager.initiateCall(options.to, void 0, {
280
- message: options.message,
281
- mode
282
- });
283
- if (!result.success) throw new Error(result.error || "smoke call failed");
284
- callId = result.callId;
285
- }
286
- if (typeof callId !== "string" || !callId) throw new Error("smoke call failed");
402
+ message: options.message,
403
+ mode: options.mode,
404
+ defaultMode: "notify",
405
+ failureMessage: "smoke call failed"
406
+ });
287
407
  if (options.json) {
288
- writeStdoutJson({
408
+ writeCliJson({
289
409
  ok: true,
290
410
  setup,
291
411
  liveCall: true,
@@ -294,245 +414,162 @@ function registerVoiceCallCli(params) {
294
414
  return;
295
415
  }
296
416
  writeSetupStatus(setup);
297
- writeStdoutLine("live-call: started %s", callId);
417
+ writeCliLine("live-call: started %s", callId);
298
418
  });
299
419
  root.command("call").description("Initiate an outbound voice call").requiredOption("-m, --message <text>", "Message to speak when call connects").option("-t, --to <phone>", "Phone number to call (E.164 format, uses config toNumber if not set)").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(async (options) => {
300
- await initiateCallViaGatewayOrRuntime({
420
+ writeCliJson({ callId: await initiateVoiceCall({
301
421
  ensureRuntime,
302
422
  config,
303
423
  method: "voicecall.initiate",
304
424
  to: options.to,
305
425
  message: options.message,
306
426
  mode: options.mode
307
- });
427
+ }) });
308
428
  });
309
429
  root.command("start").description("Alias for voicecall call").requiredOption("--to <phone>", "Phone number to call").option("--message <text>", "Message to speak when call connects").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(async (options) => {
310
- await initiateCallViaGatewayOrRuntime({
430
+ writeCliJson({ callId: await initiateVoiceCall({
311
431
  ensureRuntime,
312
432
  config,
313
433
  method: "voicecall.start",
314
434
  to: options.to,
315
435
  message: options.message,
316
436
  mode: options.mode
317
- });
437
+ }) });
318
438
  });
319
439
  root.command("continue").description("Speak a message and wait for a response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
320
- let gateway;
321
- try {
322
- gateway = await callVoiceCallGateway("voicecall.continue.start", {
323
- callId: options.callId,
324
- message: options.message
325
- }, { timeoutMs: resolveGatewayOperationTimeoutMs(config) });
326
- } catch (err) {
327
- if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) throw err;
328
- gateway = await callVoiceCallGateway("voicecall.continue", {
329
- callId: options.callId,
330
- message: options.message
331
- }, { timeoutMs: resolveGatewayContinueTimeoutMs(config) });
332
- }
333
- if (gateway.ok) {
334
- if (isRecord(gateway.payload) && typeof gateway.payload.operationId === "string") {
335
- writeStdoutJson(await pollVoiceCallContinueGateway({
336
- operationId: readGatewayOperationId(gateway.payload),
337
- timeoutMs: readGatewayPollTimeoutMs(gateway.payload, resolveGatewayContinueTimeoutMs(config))
338
- }));
339
- return;
340
- }
341
- writeStdoutJson(gateway.payload);
342
- return;
343
- }
344
- const result = await (await ensureRuntime()).manager.continueCall(options.callId, options.message);
345
- if (!result.success) throw new Error(result.error || "continue failed");
346
- writeStdoutJson(result);
347
- });
348
- root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
349
- const gateway = await callVoiceCallGateway("voicecall.speak", {
440
+ const gatewayParams = {
350
441
  callId: options.callId,
351
442
  message: options.message
443
+ };
444
+ const continueTimeoutMs = resolveContinueTimeout(config);
445
+ await runGatewayManagerCommand({
446
+ config,
447
+ ensureRuntime,
448
+ gatewayCall: async () => {
449
+ try {
450
+ return await callVoiceCallGateway("voicecall.continue.start", gatewayParams, { timeoutMs: resolveOperationTimeout(config) });
451
+ } catch (err) {
452
+ if (!isUnknownMethod(err, "voicecall.continue.start")) throw err;
453
+ return callVoiceCallGateway("voicecall.continue", gatewayParams, { timeoutMs: continueTimeoutMs });
454
+ }
455
+ },
456
+ resolveGatewayPayload: (payload) => pollContinueGateway(payload, continueTimeoutMs),
457
+ managerFallback: (manager) => manager.continueCall(options.callId, options.message),
458
+ failureLabel: "continue"
459
+ });
460
+ });
461
+ root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
462
+ await runGatewayManagerCommand({
463
+ config,
464
+ ensureRuntime,
465
+ gatewayCall: () => callVoiceCallGateway("voicecall.speak", {
466
+ callId: options.callId,
467
+ message: options.message
468
+ }),
469
+ managerFallback: (manager) => manager.speak(options.callId, options.message),
470
+ failureLabel: "speak"
352
471
  });
353
- if (gateway.ok) {
354
- writeStdoutJson(gateway.payload);
355
- return;
356
- }
357
- const result = await (await ensureRuntime()).manager.speak(options.callId, options.message);
358
- if (!result.success) throw new Error(result.error || "speak failed");
359
- writeStdoutJson(result);
360
472
  });
361
473
  root.command("dtmf").description("Send DTMF digits to an active call").requiredOption("--call-id <id>", "Call ID").requiredOption("--digits <digits>", "DTMF digits").action(async (options) => {
362
- const gateway = await callVoiceCallGateway("voicecall.dtmf", {
363
- callId: options.callId,
364
- digits: options.digits
474
+ await runGatewayManagerCommand({
475
+ config,
476
+ ensureRuntime,
477
+ gatewayCall: () => callVoiceCallGateway("voicecall.dtmf", {
478
+ callId: options.callId,
479
+ digits: options.digits
480
+ }),
481
+ managerFallback: (manager) => manager.sendDtmf(options.callId, options.digits),
482
+ failureLabel: "dtmf"
365
483
  });
366
- if (gateway.ok) {
367
- writeStdoutJson(gateway.payload);
368
- return;
369
- }
370
- const result = await (await ensureRuntime()).manager.sendDtmf(options.callId, options.digits);
371
- if (!result.success) throw new Error(result.error || "dtmf failed");
372
- writeStdoutJson(result);
373
484
  });
374
485
  root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action(async (options) => {
375
- const gateway = await callVoiceCallGateway("voicecall.end", { callId: options.callId });
376
- if (gateway.ok) {
377
- writeStdoutJson(gateway.payload);
378
- return;
379
- }
380
- const result = await (await ensureRuntime()).manager.endCall(options.callId);
381
- if (!result.success) throw new Error(result.error || "end failed");
382
- writeStdoutJson(result);
486
+ await runGatewayManagerCommand({
487
+ config,
488
+ ensureRuntime,
489
+ gatewayCall: () => callVoiceCallGateway("voicecall.end", { callId: options.callId }),
490
+ managerFallback: (manager) => manager.endCall(options.callId),
491
+ failureLabel: "end"
492
+ });
383
493
  });
384
494
  root.command("status").description("Show call status").option("--call-id <id>", "Call ID").option("--json", "Print machine-readable JSON").action(async (options) => {
385
495
  const gateway = await callVoiceCallGateway("voicecall.status", options.callId ? { callId: options.callId } : void 0);
386
496
  if (gateway.ok) {
387
497
  if (options.callId && isRecord(gateway.payload)) {
388
498
  if (gateway.payload.found === true && "call" in gateway.payload) {
389
- writeStdoutJson(gateway.payload.call);
499
+ writeCliJson(gateway.payload.call);
390
500
  return;
391
501
  }
392
502
  if (gateway.payload.found === false) {
393
- writeStdoutJson({ found: false });
394
- return;
395
- }
396
- }
397
- writeStdoutJson(gateway.payload);
398
- return;
399
- }
400
- const rt = await ensureRuntime();
401
- if (options.callId) {
402
- writeStdoutJson(await rt.manager.getCallFromMemoryOrStore(options.callId) ?? { found: false });
403
- return;
404
- }
405
- writeStdoutJson({
406
- found: true,
407
- calls: rt.manager.getActiveCalls()
408
- });
409
- });
410
- root.command("tail").description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)").option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config)).option("--since <n>", "Print last N lines first", "25").option("--poll <ms>", "Poll interval in ms", "250").action(async (options) => {
411
- const file = options.file;
412
- const since = parseVoiceCallIntOption(options.since, "--since", { min: 0 });
413
- const pollMs = parseVoiceCallIntOption(options.poll, "--poll", { min: 50 });
414
- const tailSqliteHistory = async (initialLimit) => {
415
- ensureHistoryStateRuntime();
416
- const seen = /* @__PURE__ */ new Set();
417
- const printCall = (call) => {
418
- const line = JSON.stringify(call);
419
- if (!seen.has(line)) {
420
- seen.add(line);
421
- writeStdoutLine(line);
422
- }
423
- };
424
- if (initialLimit > 0) for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) printCall(call);
425
- for (;;) {
426
- try {
427
- for (const call of await getCallHistoryFromStore(path.dirname(file), 1e3)) printCall(call);
428
- } catch {}
429
- await sleep(pollMs);
430
- }
431
- };
432
- if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
433
- const initial = fs.readFileSync(file);
434
- let decoder = new StringDecoder("utf8");
435
- const initialLines = decoder.write(initial).split("\n");
436
- let pendingLine = initialLines.pop() ?? "";
437
- const lines = initialLines.filter(Boolean);
438
- for (const line of lines.slice(Math.max(0, lines.length - since))) writeStdoutLine(line);
439
- let offset = initial.length;
440
- let lastObservedSize = initial.length;
441
- for (;;) {
442
- try {
443
- const stat = fs.statSync(file);
444
- if (stat.size < lastObservedSize) {
445
- offset = 0;
446
- decoder = new StringDecoder("utf8");
447
- pendingLine = "";
448
- }
449
- lastObservedSize = stat.size;
450
- if (stat.size > offset) {
451
- const fd = fs.openSync(file, "r");
452
- try {
453
- const buf = Buffer.alloc(stat.size - offset);
454
- const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
455
- offset += bytesRead;
456
- const text = decoder.write(buf.subarray(0, bytesRead));
457
- const completeLines = `${pendingLine}${text}`.split("\n");
458
- pendingLine = completeLines.pop() ?? "";
459
- for (const line of completeLines.filter(Boolean)) writeStdoutLine(line);
460
- } finally {
461
- fs.closeSync(fd);
462
- }
463
- }
464
- } catch {}
465
- await sleep(pollMs);
466
- }
467
- } else await tailSqliteHistory(since);
468
- });
469
- root.command("latency").description("Summarize turn latency metrics from voice-call JSONL logs").option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config)).option("--last <n>", "Analyze last N records", "200").action(async (options) => {
470
- const file = options.file;
471
- const last = parseVoiceCallIntOption(options.last, "--last", { min: 1 });
472
- if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") writeVoiceCallLatencySummary(fs.readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-last).map((line) => {
473
- try {
474
- const parsed = JSON.parse(line);
475
- return parsed.call ?? parsed;
476
- } catch {
477
- return null;
503
+ writeCliJson({ found: false });
504
+ return;
505
+ }
478
506
  }
479
- }).filter((call) => call !== null));
480
- else {
481
- ensureHistoryStateRuntime();
482
- writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
507
+ writeCliJson(gateway.payload);
508
+ return;
483
509
  }
484
- });
485
- function writeVoiceCallLatencySummary(calls) {
486
- const turnLatencyMs = [];
487
- const listenWaitMs = [];
488
- for (const call of calls) {
489
- const latency = call.metadata?.lastTurnLatencyMs;
490
- const listenWait = call.metadata?.lastTurnListenWaitMs;
491
- if (typeof latency === "number" && Number.isFinite(latency)) turnLatencyMs.push(latency);
492
- if (typeof listenWait === "number" && Number.isFinite(listenWait)) listenWaitMs.push(listenWait);
510
+ ensureHistoryStateRuntime();
511
+ const storePath = path.dirname(resolveDefaultStorePath(config));
512
+ if (options.callId) {
513
+ const persisted = await findCallMatchesInStore(storePath, options.callId);
514
+ writeCliJson(persisted.byCallId ?? persisted.byProviderCallId ?? { found: false });
515
+ return;
493
516
  }
494
- writeStdoutJson({
495
- recordsScanned: calls.length,
496
- turnLatency: summarizeSeries(turnLatencyMs),
497
- listenWait: summarizeSeries(listenWaitMs)
517
+ writeCliJson({
518
+ found: true,
519
+ calls: Array.from(loadActiveCallsFromStore(storePath).activeCalls.values())
498
520
  });
499
- }
521
+ });
522
+ registerVoiceCallLogs({
523
+ root,
524
+ defaultFile: resolveDefaultStorePath(config),
525
+ ensureHistoryStateRuntime
526
+ });
500
527
  root.command("expose").description("Enable/disable Tailscale serve/funnel for the webhook").option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel").option("--path <path>", "Tailscale path to expose (recommend matching serve.path)").option("--port <port>", "Local webhook port").option("--serve-path <path>", "Local webhook path").action(async (options) => {
501
528
  const mode = resolveMode(options.mode ?? "funnel");
502
- const servePort = parseVoiceCallIntOption(options.port ?? String(config.serve.port ?? 3334), "--port", {
529
+ const servePort = parseCliInteger(options.port ?? String(config.serve.port ?? 3334), "--port", {
503
530
  min: 1,
504
531
  max: MAX_TCP_PORT
505
532
  });
506
533
  const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
507
534
  const tsPath = options.path ?? config.tailscale?.path ?? servePath;
508
- const localUrl = `http://127.0.0.1:${servePort}`;
535
+ const streamExposurePaths = resolveVoiceCallStreamExposurePaths(config, {
536
+ publicWebhookPath: tsPath,
537
+ localWebhookPath: servePath
538
+ });
539
+ const streamPaths = streamExposurePaths.map(({ publicPath }) => publicPath);
540
+ const localUrl = `http://127.0.0.1:${servePort}${servePath}`;
509
541
  if (mode === "off") {
510
- await cleanupTailscaleExposureRoute({
511
- mode: "serve",
512
- path: tsPath
542
+ for (const exposurePath of [tsPath, ...streamPaths]) for (const tailscaleMode of ["serve", "funnel"]) await cleanupTailscaleExposureRoute({
543
+ mode: tailscaleMode,
544
+ port: config.tailscale.port,
545
+ path: exposurePath
513
546
  });
514
- await cleanupTailscaleExposureRoute({
515
- mode: "funnel",
516
- path: tsPath
517
- });
518
- writeStdoutJson({
547
+ writeCliJson({
519
548
  ok: true,
520
549
  mode: "off",
521
- path: tsPath
550
+ path: tsPath,
551
+ streamPaths
522
552
  });
523
553
  return;
524
554
  }
525
- const publicUrl = await setupTailscaleExposureRoute({
555
+ const publicUrl = await setupTailscaleExposureRoutes({
526
556
  mode,
527
- path: tsPath,
528
- localUrl
557
+ port: config.tailscale.port,
558
+ routes: [{
559
+ path: tsPath,
560
+ localUrl
561
+ }, ...streamExposurePaths.map(({ publicPath, localPath }) => ({
562
+ path: publicPath,
563
+ localUrl: `http://127.0.0.1:${servePort}${localPath}`
564
+ }))]
529
565
  });
530
566
  const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
531
567
  const enableUrl = tsInfo?.nodeId ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}` : null;
532
- writeStdoutJson({
568
+ writeCliJson({
533
569
  ok: Boolean(publicUrl),
534
570
  mode,
535
571
  path: tsPath,
572
+ streamPaths,
536
573
  localUrl,
537
574
  publicUrl,
538
575
  hint: publicUrl ? void 0 : {
@@ -543,6 +580,126 @@ function registerVoiceCallCli(params) {
543
580
  });
544
581
  }
545
582
  //#endregion
583
+ //#region extensions/voice-call/src/command-service.ts
584
+ var VoiceCallCommandInputError = class extends Error {};
585
+ function toVoiceCallStatus(call) {
586
+ return {
587
+ callId: call.callId,
588
+ ...call.providerCallId !== void 0 ? { providerCallId: call.providerCallId } : {},
589
+ provider: call.provider,
590
+ direction: call.direction,
591
+ state: call.state,
592
+ startedAt: call.startedAt,
593
+ ...call.answeredAt !== void 0 ? { answeredAt: call.answeredAt } : {},
594
+ ...call.endedAt !== void 0 ? { endedAt: call.endedAt } : {},
595
+ ...call.endReason !== void 0 ? { endReason: call.endReason } : {}
596
+ };
597
+ }
598
+ function requireInput(value, message) {
599
+ if (!value) throw new VoiceCallCommandInputError(message);
600
+ return value;
601
+ }
602
+ function requireSuccess(result, fallback) {
603
+ if (!result.success) throw new Error(result.error || fallback);
604
+ }
605
+ function createVoiceCallCommandService(ensureRuntime) {
606
+ const describeHistoricalCall = async (rt, callId) => {
607
+ const call = await rt.manager.getCallFromMemoryOrStore(callId);
608
+ if (!call) return;
609
+ const endedAt = timestampMsToIsoString(call.endedAt);
610
+ return `call is not active (${[
611
+ `last state=${call.state}`,
612
+ call.endReason ? `endReason=${call.endReason}` : void 0,
613
+ endedAt ? `endedAt=${endedAt}` : void 0
614
+ ].filter(Boolean).join(", ")})`;
615
+ };
616
+ const resolveCallMessage = async (callId, message) => {
617
+ const resolvedCallId = requireInput(callId, "callId and message required");
618
+ const resolvedMessage = requireInput(message, "callId and message required");
619
+ const rt = await ensureRuntime();
620
+ const activeCall = rt.manager.getCall(resolvedCallId) ?? rt.manager.getCallByProviderCallId(resolvedCallId);
621
+ if (!activeCall) throw new VoiceCallCommandInputError(await describeHistoricalCall(rt, resolvedCallId) ?? "Call not found");
622
+ return {
623
+ rt,
624
+ callId: activeCall.callId,
625
+ message: resolvedMessage
626
+ };
627
+ };
628
+ const prepareContinue = async (callId, message) => {
629
+ const request = await resolveCallMessage(callId, message);
630
+ return {
631
+ rt: request.rt,
632
+ callId: request.callId,
633
+ run: async () => {
634
+ const result = await request.rt.manager.continueCall(request.callId, request.message);
635
+ requireSuccess(result, "continue failed");
636
+ return {
637
+ success: true,
638
+ transcript: result.transcript
639
+ };
640
+ }
641
+ };
642
+ };
643
+ return {
644
+ prepareContinue,
645
+ async initiate(params, missingToMessage = "to required") {
646
+ const rt = await ensureRuntime();
647
+ const to = requireInput(params.to ?? rt.config.toNumber, missingToMessage);
648
+ const result = await rt.manager.initiateCall(to, params.sessionKey, {
649
+ message: params.message,
650
+ mode: params.mode,
651
+ dtmfSequence: params.dtmfSequence,
652
+ ...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
653
+ ...params.agentId ? { agentId: params.agentId } : {}
654
+ });
655
+ requireSuccess(result, "initiate failed");
656
+ return {
657
+ callId: result.callId,
658
+ initiated: true
659
+ };
660
+ },
661
+ async continueCall(callId, message) {
662
+ return await (await prepareContinue(callId, message)).run();
663
+ },
664
+ async speak(params) {
665
+ const request = await resolveCallMessage(params.callId, params.message);
666
+ if (request.rt.config.realtime.enabled) {
667
+ const realtimeResult = request.rt.webhookServer.speakRealtime(request.callId, request.message);
668
+ if (realtimeResult.success) return { success: true };
669
+ if (params.allowTwimlFallback === false) return {
670
+ success: false,
671
+ error: realtimeResult.error ?? "Realtime bridge is not active"
672
+ };
673
+ }
674
+ requireSuccess(await request.rt.manager.speak(request.callId, request.message), "speak failed");
675
+ return { success: true };
676
+ },
677
+ async sendDtmf(callId, digits) {
678
+ const resolvedCallId = requireInput(callId, "callId and digits required");
679
+ const resolvedDigits = requireInput(digits, "callId and digits required");
680
+ requireSuccess(await (await ensureRuntime()).manager.sendDtmf(resolvedCallId, resolvedDigits), "dtmf failed");
681
+ return { success: true };
682
+ },
683
+ async endCall(callId) {
684
+ const resolvedCallId = requireInput(callId, "callId required");
685
+ requireSuccess(await (await ensureRuntime()).manager.endCall(resolvedCallId), "end failed");
686
+ return { success: true };
687
+ },
688
+ async status(callId) {
689
+ const rt = await ensureRuntime();
690
+ if (!callId) return {
691
+ found: true,
692
+ calls: rt.manager.getActiveCalls().map(toVoiceCallStatus)
693
+ };
694
+ const call = await rt.manager.getCallFromMemoryOrStore(callId);
695
+ return call ? {
696
+ found: true,
697
+ call: toVoiceCallStatus(call)
698
+ } : { found: false };
699
+ }
700
+ };
701
+ }
702
+ //#endregion
546
703
  //#region extensions/voice-call/src/gateway-continue-operation.ts
547
704
  const VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS = 3e4;
548
705
  const VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS = 300 * 1e3;
@@ -569,21 +726,9 @@ function createVoiceCallContinueOperationStore(params) {
569
726
  startedAtMs,
570
727
  pollTimeoutMs
571
728
  });
572
- request.rt.manager.continueCall(request.callId, request.message).then((result) => {
729
+ request.run().then((result) => {
573
730
  const current = operations.get(operationId);
574
731
  if (!current || current.status !== "pending") return;
575
- if (!result.success) {
576
- operations.set(operationId, {
577
- operationId,
578
- status: "failed",
579
- callId: request.callId,
580
- startedAtMs,
581
- completedAtMs: Date.now(),
582
- pollTimeoutMs,
583
- error: result.error || "continue failed"
584
- });
585
- return;
586
- }
587
732
  operations.set(operationId, {
588
733
  operationId,
589
734
  status: "completed",
@@ -661,223 +806,15 @@ function createVoiceCallContinueOperationStore(params) {
661
806
  //#region extensions/voice-call/index.ts
662
807
  const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" };
663
808
  const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" };
664
- const voiceCallConfigSchema = {
665
- parse(value) {
666
- const config = asOptionalRecord(value) ?? {};
667
- const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
668
- return VoiceCallConfigSchema.parse({
669
- ...config,
670
- enabled,
671
- provider: config.provider ?? (enabled ? "mock" : void 0)
672
- });
673
- },
674
- uiHints: {
675
- provider: {
676
- label: "Provider",
677
- help: "Use twilio, telnyx, or mock for dev/no-network."
678
- },
679
- fromNumber: {
680
- label: "From Number",
681
- placeholder: "+15550001234"
682
- },
683
- toNumber: {
684
- label: "Default To Number",
685
- placeholder: "+15550001234"
686
- },
687
- inboundPolicy: { label: "Inbound Policy" },
688
- allowFrom: { label: "Inbound Allowlist" },
689
- inboundGreeting: {
690
- label: "Inbound Greeting",
691
- advanced: true
692
- },
693
- numbers: {
694
- label: "Per-number Routing",
695
- help: "Inbound overrides keyed by dialed E.164 number.",
696
- advanced: true
697
- },
698
- "telnyx.apiKey": {
699
- label: "Telnyx API Key",
700
- sensitive: true
701
- },
702
- "telnyx.connectionId": { label: "Telnyx Connection ID" },
703
- "telnyx.publicKey": {
704
- label: "Telnyx Public Key",
705
- sensitive: true
706
- },
707
- "twilio.accountSid": { label: "Twilio Account SID" },
708
- "twilio.authToken": {
709
- label: "Twilio Auth Token",
710
- sensitive: true
711
- },
712
- "twilio.region": {
713
- label: "Twilio Region",
714
- advanced: true
715
- },
716
- "outbound.defaultMode": { label: "Default Call Mode" },
717
- "outbound.notifyHangupDelaySec": {
718
- label: "Notify Hangup Delay (sec)",
719
- advanced: true
720
- },
721
- "serve.port": { label: "Webhook Port" },
722
- "serve.bind": { label: "Webhook Bind" },
723
- "serve.path": { label: "Webhook Path" },
724
- "tailscale.mode": {
725
- label: "Tailscale Mode",
726
- advanced: true
727
- },
728
- "tailscale.path": {
729
- label: "Tailscale Path",
730
- advanced: true
731
- },
732
- "tunnel.provider": {
733
- label: "Tunnel Provider",
734
- advanced: true
735
- },
736
- "tunnel.ngrokAuthToken": {
737
- label: "ngrok Auth Token",
738
- sensitive: true,
739
- advanced: true
740
- },
741
- "tunnel.ngrokDomain": {
742
- label: "ngrok Domain",
743
- advanced: true
744
- },
745
- "tunnel.allowNgrokFreeTierLoopbackBypass": {
746
- label: "Allow ngrok Free Tier (Loopback Bypass)",
747
- advanced: true
748
- },
749
- "streaming.enabled": {
750
- label: "Enable Streaming",
751
- help: "Classic streaming transcription currently requires the Twilio call provider.",
752
- advanced: true
753
- },
754
- "streaming.provider": {
755
- label: "Streaming Provider",
756
- help: "Uses the first registered realtime transcription provider when unset.",
757
- advanced: true
758
- },
759
- "streaming.providers": {
760
- label: "Streaming Provider Config",
761
- advanced: true
762
- },
763
- "streaming.streamPath": {
764
- label: "Media Stream Path",
765
- advanced: true
766
- },
767
- "realtime.enabled": {
768
- label: "Enable Realtime Voice",
769
- advanced: true
770
- },
771
- "realtime.provider": {
772
- label: "Realtime Voice Provider",
773
- help: "Uses the first registered realtime voice provider when unset.",
774
- advanced: true
775
- },
776
- "realtime.streamPath": {
777
- label: "Realtime Stream Path",
778
- advanced: true
779
- },
780
- "realtime.instructions": {
781
- label: "Realtime Instructions",
782
- advanced: true
783
- },
784
- "realtime.toolPolicy": {
785
- label: "Realtime Tool Policy",
786
- help: "Controls the shared openclaw_agent_consult tool.",
787
- advanced: true
788
- },
789
- "realtime.consultPolicy": {
790
- label: "Realtime Consult Policy",
791
- help: "Guides when the realtime voice model should call openclaw_agent_consult.",
792
- advanced: true
793
- },
794
- "realtime.fastContext.enabled": {
795
- label: "Enable Fast Realtime Context",
796
- help: "Searches memory/session context before the full consult agent.",
797
- advanced: true
798
- },
799
- "realtime.fastContext.timeoutMs": {
800
- label: "Fast Context Timeout",
801
- advanced: true
802
- },
803
- "realtime.fastContext.maxResults": {
804
- label: "Fast Context Result Limit",
805
- advanced: true
806
- },
807
- "realtime.fastContext.sources": {
808
- label: "Fast Context Sources",
809
- advanced: true
810
- },
811
- "realtime.fastContext.fallbackToConsult": {
812
- label: "Fallback To Full Consult",
813
- advanced: true
814
- },
815
- "realtime.agentContext.enabled": {
816
- label: "Enable Agent Voice Context",
817
- help: "Injects a compact agent identity and workspace context capsule into realtime voice instructions.",
818
- advanced: true
819
- },
820
- "realtime.agentContext.maxChars": {
821
- label: "Agent Voice Context Limit",
822
- advanced: true
823
- },
824
- "realtime.agentContext.includeIdentity": {
825
- label: "Include Agent Identity",
826
- advanced: true
827
- },
828
- "realtime.agentContext.includeWorkspaceFiles": {
829
- label: "Include Agent Workspace Files",
830
- advanced: true
831
- },
832
- "realtime.agentContext.files": {
833
- label: "Agent Voice Context Files",
834
- advanced: true
835
- },
836
- "realtime.providers": {
837
- label: "Realtime Provider Config",
838
- advanced: true
839
- },
840
- "tts.provider": {
841
- label: "TTS Provider Override",
842
- help: "Deep-merges with tts (Microsoft is ignored for calls).",
843
- advanced: true
844
- },
845
- "tts.providers": {
846
- label: "TTS Provider Config",
847
- advanced: true
848
- },
849
- publicUrl: {
850
- label: "Public Webhook URL",
851
- advanced: true
852
- },
853
- skipSignatureVerification: {
854
- label: "Skip Signature Verification",
855
- advanced: true
856
- },
857
- store: {
858
- label: "Call Log Store Path",
859
- advanced: true
860
- },
861
- agentId: {
862
- label: "Response Agent ID",
863
- help: "Agent workspace used for voice response generation. Defaults to \"main\".",
864
- advanced: true
865
- },
866
- responseModel: {
867
- label: "Response Model",
868
- help: "Optional override. Falls back to the runtime default model when unset.",
869
- advanced: true
870
- },
871
- responseSystemPrompt: {
872
- label: "Response System Prompt",
873
- advanced: true
874
- },
875
- responseTimeoutMs: {
876
- label: "Response Timeout (ms)",
877
- advanced: true
878
- }
879
- }
880
- };
809
+ const voiceCallConfigSchema = { parse(value) {
810
+ const config = asOptionalRecord(value) ?? {};
811
+ const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
812
+ return VoiceCallConfigSchema.parse({
813
+ ...config,
814
+ enabled,
815
+ provider: config.provider ?? (enabled ? "mock" : void 0)
816
+ });
817
+ } };
881
818
  const VoiceCallToolSchema = Type.Union([
882
819
  Type.Object({
883
820
  action: Type.Literal("initiate_call"),
@@ -885,7 +822,6 @@ const VoiceCallToolSchema = Type.Union([
885
822
  message: Type.String({ description: "Intro message" }),
886
823
  mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
887
824
  sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
888
- requesterSessionKey: Type.Optional(Type.String({ description: "OpenClaw session key that initiated the call" })),
889
825
  dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
890
826
  }),
891
827
  Type.Object({
@@ -917,38 +853,40 @@ const VoiceCallToolSchema = Type.Union([
917
853
  sid: Type.Optional(Type.String({ description: "Call SID" })),
918
854
  message: Type.Optional(Type.String({ description: "Optional intro message" })),
919
855
  sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
920
- requesterSessionKey: Type.Optional(Type.String({ description: "OpenClaw session key that initiated the call" })),
921
856
  dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
922
857
  })
923
858
  ]);
924
- function asParamRecord(params) {
925
- return params && typeof params === "object" && !Array.isArray(params) ? params : {};
926
- }
927
859
  function isCliOnlyProcess() {
928
860
  return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
929
861
  }
930
- function toVoiceCallStatus(call) {
931
- return {
932
- callId: call.callId,
933
- ...call.providerCallId !== void 0 ? { providerCallId: call.providerCallId } : {},
934
- provider: call.provider,
935
- direction: call.direction,
936
- state: call.state,
937
- startedAt: call.startedAt,
938
- ...call.answeredAt !== void 0 ? { answeredAt: call.answeredAt } : {},
939
- ...call.endedAt !== void 0 ? { endedAt: call.endedAt } : {},
940
- ...call.endReason !== void 0 ? { endReason: call.endReason } : {}
941
- };
862
+ const VOICE_CALL_RUNTIME_COORDINATOR_KEY = Symbol.for("openclaw.voice-call.runtimeCoordinator");
863
+ var VoiceCallRuntimeLifecycleError = class extends Error {};
864
+ function getVoiceCallRuntimeCoordinator() {
865
+ return resolveGlobalSingleton(VOICE_CALL_RUNTIME_COORDINATOR_KEY, () => ({ epochCounter: 0 }));
866
+ }
867
+ function activateVoiceCallRuntimeGeneration(coordinator, registration, generation) {
868
+ if (registration.epoch < (coordinator.current?.epoch ?? 0) || registration.generation !== generation) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
869
+ if (generation.retired) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation is retired; use the current plugin registration");
870
+ if (coordinator.current !== registration) {
871
+ if (coordinator.current) coordinator.current.generation.retired = true;
872
+ coordinator.current = registration;
873
+ }
942
874
  }
943
- const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime");
944
- const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise");
945
- const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise");
946
- function getVoiceCallRuntimeGlobalState() {
947
- const state = globalThis;
948
- state[VOICE_CALL_RUNTIME_KEY] ??= null;
949
- state[VOICE_CALL_RUNTIME_PROMISE_KEY] ??= null;
950
- state[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] ??= null;
951
- return state;
875
+ function stopVoiceCallRuntimeGeneration(coordinator, generation) {
876
+ const ownedSlot = coordinator.slot?.owner === generation ? coordinator.slot : void 0;
877
+ if (!ownedSlot || ownedSlot.state === "stopping") return ownedSlot?.promise ?? Promise.resolve();
878
+ const stopPromise = Promise.resolve().then(async () => {
879
+ await (ownedSlot.state === "running" ? ownedSlot.runtime : await ownedSlot.promise).stop();
880
+ });
881
+ const stoppingSlot = {
882
+ state: "stopping",
883
+ owner: generation,
884
+ promise: stopPromise
885
+ };
886
+ if (coordinator.slot === ownedSlot) coordinator.slot = stoppingSlot;
887
+ return stopPromise.finally(() => {
888
+ if (coordinator.slot === stoppingSlot) coordinator.slot = void 0;
889
+ });
952
890
  }
953
891
  var voice_call_default = definePluginEntry({
954
892
  id: "voice-call",
@@ -958,24 +896,56 @@ var voice_call_default = definePluginEntry({
958
896
  register(api) {
959
897
  const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
960
898
  const validation = validateProviderConfig(config);
961
- const runtimeState = getVoiceCallRuntimeGlobalState();
899
+ const runtimeCoordinator = getVoiceCallRuntimeCoordinator();
900
+ const runtimeRegistration = api.registrationMode !== "full" && runtimeCoordinator.current ? runtimeCoordinator.current : {
901
+ epoch: ++runtimeCoordinator.epochCounter,
902
+ generation: { retired: false }
903
+ };
962
904
  const continueOperationStore = createVoiceCallContinueOperationStore({
963
905
  config,
964
906
  coreConfig: api.config
965
907
  });
966
- const ensureRuntime = async () => {
908
+ const activateRuntimeGeneration = (generation) => activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeRegistration, generation);
909
+ const ensureRuntimeForGeneration = async (runtimeGeneration) => {
910
+ activateRuntimeGeneration(runtimeGeneration);
967
911
  if (!config.enabled) throw new Error("Voice call disabled in plugin config");
968
912
  if (!validation.valid) throw new Error(validation.errors.join("; "));
969
913
  while (true) {
970
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
971
- await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
972
- continue;
914
+ activateRuntimeGeneration(runtimeGeneration);
915
+ const slot = runtimeCoordinator.slot;
916
+ if (slot) {
917
+ if (slot.owner !== runtimeGeneration) {
918
+ if (slot.owner.retired) {
919
+ await stopVoiceCallRuntimeGeneration(runtimeCoordinator, slot.owner);
920
+ continue;
921
+ }
922
+ throw new VoiceCallRuntimeLifecycleError("A previous voice call runtime generation is still active; retry after it stops");
923
+ }
924
+ if (slot.state === "running") return slot.runtime;
925
+ if (slot.state === "stopping") {
926
+ await slot.promise;
927
+ continue;
928
+ }
929
+ let createdRuntime;
930
+ try {
931
+ createdRuntime = await slot.promise;
932
+ } catch (err) {
933
+ if (runtimeCoordinator.slot === slot) runtimeCoordinator.slot = void 0;
934
+ throw err;
935
+ }
936
+ activateRuntimeGeneration(runtimeGeneration);
937
+ if (runtimeCoordinator.slot !== slot) continue;
938
+ runtimeCoordinator.slot = {
939
+ state: "running",
940
+ owner: runtimeGeneration,
941
+ runtime: createdRuntime
942
+ };
943
+ return createdRuntime;
973
944
  }
974
- const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
975
- if (runtime) return runtime;
976
- let runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
977
- if (!runtimePromise) {
978
- runtimePromise = createVoiceCallRuntime({
945
+ const startingSlot = {
946
+ state: "starting",
947
+ owner: runtimeGeneration,
948
+ promise: createVoiceCallRuntime({
979
949
  config,
980
950
  coreConfig: api.config,
981
951
  fullConfig: api.config,
@@ -983,279 +953,76 @@ var voice_call_default = definePluginEntry({
983
953
  stateRuntime: api.runtime.state,
984
954
  ttsRuntime: api.runtime.tts,
985
955
  logger: api.logger
986
- });
987
- runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = runtimePromise;
988
- }
989
- try {
990
- const createdRuntime = await runtimePromise;
991
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) continue;
992
- if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] !== runtimePromise) continue;
993
- runtimeState[VOICE_CALL_RUNTIME_KEY] = createdRuntime;
994
- return createdRuntime;
995
- } catch (err) {
996
- if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] === runtimePromise) {
997
- runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
998
- runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
999
- }
1000
- throw err;
1001
- }
1002
- }
1003
- };
1004
- const respondError = (respond, message, code = ErrorCodes.UNAVAILABLE) => {
1005
- respond(false, void 0, errorShape(code, message));
1006
- };
1007
- const sendError = (respond, err) => {
1008
- respondError(respond, formatErrorMessage(err));
1009
- };
1010
- const describeHistoricalCall = async (rt, callId) => {
1011
- const call = await rt.manager.getCallFromMemoryOrStore(callId);
1012
- if (!call) return;
1013
- const endedAt = timestampMsToIsoString(call.endedAt);
1014
- return `call is not active (${[
1015
- `last state=${call.state}`,
1016
- call.endReason ? `endReason=${call.endReason}` : void 0,
1017
- endedAt ? `endedAt=${endedAt}` : void 0
1018
- ].filter(Boolean).join(", ")})`;
1019
- };
1020
- const resolveCallMessageRequest = async (params) => {
1021
- const callId = normalizeOptionalString(params?.callId) ?? "";
1022
- const message = normalizeOptionalString(params?.message) ?? "";
1023
- if (!callId || !message) return { error: "callId and message required" };
1024
- const rt = await ensureRuntime();
1025
- const activeCall = rt.manager.getCall(callId) ?? rt.manager.getCallByProviderCallId(callId);
1026
- if (activeCall) return {
1027
- rt,
1028
- callId: activeCall.callId,
1029
- message
1030
- };
1031
- return { error: await describeHistoricalCall(rt, callId) ?? "Call not found" };
1032
- };
1033
- const initiateCallAndRespond = async (params) => {
1034
- const result = await params.rt.manager.initiateCall(params.to, params.sessionKey, {
1035
- message: params.message,
1036
- mode: params.mode,
1037
- dtmfSequence: params.dtmfSequence,
1038
- ...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
1039
- ...params.agentId ? { agentId: params.agentId } : {}
1040
- });
1041
- if (!result.success) {
1042
- respondError(params.respond, result.error || "initiate failed");
1043
- return;
1044
- }
1045
- params.respond(true, {
1046
- callId: result.callId,
1047
- initiated: true
1048
- });
1049
- };
1050
- const respondToCallMessageAction = async (params) => {
1051
- const request = await resolveCallMessageRequest(params.requestParams);
1052
- if ("error" in request) {
1053
- respondError(params.respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
1054
- return;
1055
- }
1056
- const result = await params.action(request);
1057
- if (!result.success) {
1058
- respondError(params.respond, result.error || params.failure);
1059
- return;
956
+ })
957
+ };
958
+ runtimeCoordinator.slot = startingSlot;
1060
959
  }
1061
- params.respond(true, params.includeTranscript ? {
1062
- success: true,
1063
- transcript: result.transcript
1064
- } : { success: true });
1065
960
  };
1066
- api.registerGatewayMethod("voicecall.initiate", async ({ params, respond }) => {
1067
- try {
1068
- const message = normalizeOptionalString(params?.message) ?? "";
1069
- if (!message) {
1070
- respondError(respond, "message required", ErrorCodes.INVALID_REQUEST);
1071
- return;
1072
- }
1073
- const rt = await ensureRuntime();
1074
- const to = normalizeOptionalString(params?.to) ?? rt.config.toNumber;
1075
- if (!to) {
1076
- respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
1077
- return;
1078
- }
1079
- const mode = params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0;
1080
- await initiateCallAndRespond({
1081
- rt,
1082
- respond,
1083
- to,
1084
- message,
1085
- mode,
1086
- sessionKey: normalizeOptionalString(params?.sessionKey),
1087
- requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey)
1088
- });
1089
- } catch (err) {
1090
- sendError(respond, err);
1091
- }
1092
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1093
- api.registerGatewayMethod("voicecall.continue", async ({ params, respond }) => {
1094
- try {
1095
- await respondToCallMessageAction({
1096
- requestParams: params,
1097
- respond,
1098
- action: (request) => request.rt.manager.continueCall(request.callId, request.message),
1099
- failure: "continue failed",
1100
- includeTranscript: true
1101
- });
1102
- } catch (err) {
1103
- sendError(respond, err);
1104
- }
1105
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1106
- api.registerGatewayMethod("voicecall.continue.start", async ({ params, respond }) => {
1107
- try {
1108
- const request = await resolveCallMessageRequest(params);
1109
- if ("error" in request) {
1110
- respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
1111
- return;
1112
- }
1113
- respond(true, continueOperationStore.start(request));
1114
- } catch (err) {
1115
- sendError(respond, err);
1116
- }
1117
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1118
- api.registerGatewayMethod("voicecall.continue.result", async ({ params, respond }) => {
961
+ const ensureRuntime = async (runtimeGeneration = runtimeRegistration.generation) => {
1119
962
  try {
1120
- const operationId = normalizeOptionalString(params?.operationId) ?? "";
1121
- if (!operationId) {
1122
- respondError(respond, "operationId required", ErrorCodes.INVALID_REQUEST);
1123
- return;
1124
- }
1125
- const operation = continueOperationStore.read(operationId);
1126
- if (!operation.ok) {
1127
- respondError(respond, operation.error, ErrorCodes.INVALID_REQUEST);
1128
- return;
1129
- }
1130
- respond(true, operation.payload);
1131
- } catch (err) {
1132
- sendError(respond, err);
1133
- }
1134
- }, VOICE_CALL_READ_METHOD_SCOPE);
1135
- api.registerGatewayMethod("voicecall.speak", async ({ params, respond }) => {
1136
- try {
1137
- const request = await resolveCallMessageRequest(params);
1138
- if ("error" in request) {
1139
- respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
1140
- return;
1141
- }
1142
- if (request.rt.config.realtime.enabled) {
1143
- const realtimeResult = request.rt.webhookServer.speakRealtime(request.callId, request.message);
1144
- if (realtimeResult.success) {
1145
- respond(true, { success: true });
1146
- return;
1147
- }
1148
- if (params?.allowTwimlFallback === false) {
1149
- respond(true, {
1150
- success: false,
1151
- error: realtimeResult.error ?? "Realtime bridge is not active"
1152
- });
1153
- return;
1154
- }
1155
- }
1156
- const result = await request.rt.manager.speak(request.callId, request.message);
1157
- if (!result.success) {
1158
- respondError(respond, result.error || "speak failed");
1159
- return;
1160
- }
1161
- respond(true, { success: true });
1162
- } catch (err) {
1163
- sendError(respond, err);
1164
- }
1165
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1166
- api.registerGatewayMethod("voicecall.dtmf", async ({ params, respond }) => {
1167
- try {
1168
- const callId = normalizeOptionalString(params?.callId) ?? "";
1169
- const digits = normalizeOptionalString(params?.digits) ?? "";
1170
- if (!callId || !digits) {
1171
- respondError(respond, "callId and digits required", ErrorCodes.INVALID_REQUEST);
1172
- return;
1173
- }
1174
- const result = await (await ensureRuntime()).manager.sendDtmf(callId, digits);
1175
- if (!result.success) {
1176
- respondError(respond, result.error || "dtmf failed");
1177
- return;
1178
- }
1179
- respond(true, { success: true });
963
+ const runtime = await ensureRuntimeForGeneration(runtimeGeneration);
964
+ runtimeGeneration.serviceHealth?.clearFailure();
965
+ return runtime;
1180
966
  } catch (err) {
1181
- sendError(respond, err);
967
+ if (!(err instanceof VoiceCallRuntimeLifecycleError)) runtimeGeneration.serviceHealth?.reportFailure(err);
968
+ throw err;
1182
969
  }
1183
- }, VOICE_CALL_WRITE_METHOD_SCOPE);
1184
- api.registerGatewayMethod("voicecall.end", async ({ params, respond }) => {
1185
- try {
1186
- const callId = normalizeOptionalString(params?.callId) ?? "";
1187
- if (!callId) {
1188
- respondError(respond, "callId required", ErrorCodes.INVALID_REQUEST);
1189
- return;
1190
- }
1191
- const result = await (await ensureRuntime()).manager.endCall(callId);
1192
- if (!result.success) {
1193
- respondError(respond, result.error || "end failed");
1194
- return;
970
+ };
971
+ const commands = createVoiceCallCommandService(ensureRuntime);
972
+ const registerGatewayCommand = (method, handler, scope) => {
973
+ api.registerGatewayMethod(method, async (options) => {
974
+ try {
975
+ options.respond(true, await handler(options));
976
+ } catch (err) {
977
+ const code = err instanceof VoiceCallCommandInputError ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE;
978
+ options.respond(false, void 0, errorShape(code, formatErrorMessage(err)));
1195
979
  }
1196
- respond(true, { success: true });
1197
- } catch (err) {
1198
- sendError(respond, err);
1199
- }
980
+ }, scope);
981
+ };
982
+ registerGatewayCommand("voicecall.initiate", async ({ params }) => {
983
+ const message = normalizeOptionalString(params?.message);
984
+ if (!message) throw new VoiceCallCommandInputError("message required");
985
+ return await commands.initiate({
986
+ to: normalizeOptionalString(params?.to),
987
+ message,
988
+ mode: params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0,
989
+ sessionKey: normalizeOptionalString(params?.sessionKey),
990
+ requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey)
991
+ });
1200
992
  }, VOICE_CALL_WRITE_METHOD_SCOPE);
1201
- api.registerGatewayMethod("voicecall.status", async ({ params, respond }) => {
1202
- try {
1203
- const raw = normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? "";
1204
- const rt = await ensureRuntime();
1205
- if (!raw) {
1206
- respond(true, {
1207
- found: true,
1208
- calls: rt.manager.getActiveCalls().map(toVoiceCallStatus)
1209
- });
1210
- return;
1211
- }
1212
- const call = await rt.manager.getCallFromMemoryOrStore(raw);
1213
- if (!call) {
1214
- respond(true, { found: false });
1215
- return;
1216
- }
1217
- respond(true, {
1218
- found: true,
1219
- call: toVoiceCallStatus(call)
1220
- });
1221
- } catch (err) {
1222
- sendError(respond, err);
1223
- }
993
+ registerGatewayCommand("voicecall.continue", ({ params }) => commands.continueCall(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.message)), VOICE_CALL_WRITE_METHOD_SCOPE);
994
+ registerGatewayCommand("voicecall.continue.start", async ({ params }) => continueOperationStore.start(await commands.prepareContinue(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.message))), VOICE_CALL_WRITE_METHOD_SCOPE);
995
+ registerGatewayCommand("voicecall.continue.result", ({ params }) => {
996
+ const operationId = normalizeOptionalString(params?.operationId);
997
+ if (!operationId) throw new VoiceCallCommandInputError("operationId required");
998
+ const operation = continueOperationStore.read(operationId);
999
+ if (!operation.ok) throw new VoiceCallCommandInputError(operation.error);
1000
+ return operation.payload;
1224
1001
  }, VOICE_CALL_READ_METHOD_SCOPE);
1225
- api.registerGatewayMethod("voicecall.start", async ({ params, client, respond }) => {
1226
- try {
1227
- const to = normalizeOptionalString(params?.to) ?? "";
1228
- const message = normalizeOptionalString(params?.message) ?? "";
1229
- const dtmfSequence = normalizeOptionalString(params?.dtmfSequence);
1230
- const sessionKey = normalizeOptionalString(params?.sessionKey);
1231
- const requesterSessionKey = normalizeOptionalString(params?.requesterSessionKey);
1232
- const requestedAgentId = normalizeOptionalString(params?.agentId);
1233
- const normalizedAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : void 0;
1234
- const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
1235
- if (requestedAgentId && (!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())) {
1236
- respondError(respond, "agentId requires a trusted plugin caller and a valid agent id", ErrorCodes.INVALID_REQUEST);
1237
- return;
1238
- }
1239
- if (!to) {
1240
- respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
1241
- return;
1242
- }
1243
- const mode = params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0;
1244
- const rt = await ensureRuntime();
1245
- await initiateCallAndRespond({
1246
- rt,
1247
- respond,
1248
- to,
1249
- message: message || void 0,
1250
- mode,
1251
- dtmfSequence,
1252
- sessionKey,
1253
- ...requesterSessionKey ? { requesterSessionKey } : {},
1254
- ...normalizedAgentId ? { agentId: normalizedAgentId } : {}
1255
- });
1256
- } catch (err) {
1257
- sendError(respond, err);
1258
- }
1002
+ registerGatewayCommand("voicecall.speak", ({ params }) => commands.speak({
1003
+ callId: normalizeOptionalString(params?.callId),
1004
+ message: normalizeOptionalString(params?.message),
1005
+ allowTwimlFallback: params?.allowTwimlFallback !== false
1006
+ }), VOICE_CALL_WRITE_METHOD_SCOPE);
1007
+ registerGatewayCommand("voicecall.dtmf", ({ params }) => commands.sendDtmf(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.digits)), VOICE_CALL_WRITE_METHOD_SCOPE);
1008
+ registerGatewayCommand("voicecall.end", ({ params }) => commands.endCall(normalizeOptionalString(params?.callId)), VOICE_CALL_WRITE_METHOD_SCOPE);
1009
+ registerGatewayCommand("voicecall.status", ({ params }) => commands.status(normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid)), VOICE_CALL_READ_METHOD_SCOPE);
1010
+ registerGatewayCommand("voicecall.start", async ({ params, client }) => {
1011
+ const to = normalizeOptionalString(params?.to);
1012
+ const requestedAgentId = normalizeOptionalString(params?.agentId);
1013
+ const normalizedAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : void 0;
1014
+ const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
1015
+ if (requestedAgentId && (!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())) throw new VoiceCallCommandInputError("agentId requires a trusted plugin caller and a valid agent id");
1016
+ if (!to) throw new VoiceCallCommandInputError("to required");
1017
+ return await commands.initiate({
1018
+ to,
1019
+ message: normalizeOptionalString(params?.message),
1020
+ mode: params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0,
1021
+ dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
1022
+ sessionKey: normalizeOptionalString(params?.sessionKey),
1023
+ requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
1024
+ agentId: normalizedAgentId
1025
+ });
1259
1026
  }, VOICE_CALL_WRITE_METHOD_SCOPE);
1260
1027
  api.registerTool((toolContext) => ({
1261
1028
  name: "voice_call",
@@ -1263,94 +1030,52 @@ var voice_call_default = definePluginEntry({
1263
1030
  description: "Make phone calls and have voice conversations via the voice-call plugin.",
1264
1031
  parameters: VoiceCallToolSchema,
1265
1032
  async execute(_toolCallId, params) {
1266
- const rawParams = asParamRecord(params);
1267
- const agentId = normalizeOptionalString(toolContext.agentId);
1033
+ const rawParams = asNonArrayRecord(params);
1034
+ const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
1035
+ const contextAgentId = normalizeOptionalString(toolContext.agentId) ?? parseAgentSessionKey(requesterSessionKey)?.agentId;
1036
+ const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : void 0;
1268
1037
  try {
1269
- const rt = await ensureRuntime();
1038
+ await ensureRuntime();
1270
1039
  if (typeof rawParams.action === "string") switch (rawParams.action) {
1271
1040
  case "initiate_call": {
1272
- const message = normalizeOptionalString(rawParams.message) ?? "";
1273
- if (!message) throw new Error("message required");
1274
- const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
1275
- if (!to) throw new Error("to required");
1276
- const result = await rt.manager.initiateCall(to, void 0, {
1041
+ const message = normalizeOptionalString(rawParams.message);
1042
+ if (!message) throw new VoiceCallCommandInputError("message required");
1043
+ return jsonResult(await commands.initiate({
1044
+ to: normalizeOptionalString(rawParams.to),
1277
1045
  message,
1278
1046
  dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
1279
1047
  mode: rawParams.mode === "notify" || rawParams.mode === "conversation" ? rawParams.mode : void 0,
1280
- ...agentId ? { agentId } : {}
1281
- });
1282
- if (!result.success) throw new Error(result.error || "initiate failed");
1283
- return jsonResult({
1284
- callId: result.callId,
1285
- initiated: true
1286
- });
1287
- }
1288
- case "continue_call": {
1289
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1290
- const message = normalizeOptionalString(rawParams.message) ?? "";
1291
- if (!callId || !message) throw new Error("callId and message required");
1292
- const result = await rt.manager.continueCall(callId, message);
1293
- if (!result.success) throw new Error(result.error || "continue failed");
1294
- return jsonResult({
1295
- success: true,
1296
- transcript: result.transcript
1297
- });
1298
- }
1299
- case "speak_to_user": {
1300
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1301
- const message = normalizeOptionalString(rawParams.message) ?? "";
1302
- if (!callId || !message) throw new Error("callId and message required");
1303
- const result = await rt.manager.speak(callId, message);
1304
- if (!result.success) throw new Error(result.error || "speak failed");
1305
- return jsonResult({ success: true });
1306
- }
1307
- case "send_dtmf": {
1308
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1309
- const digits = normalizeOptionalString(rawParams.digits) ?? "";
1310
- if (!callId || !digits) throw new Error("callId and digits required");
1311
- const result = await rt.manager.sendDtmf(callId, digits);
1312
- if (!result.success) throw new Error(result.error || "dtmf failed");
1313
- return jsonResult({ success: true });
1314
- }
1315
- case "end_call": {
1316
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1317
- if (!callId) throw new Error("callId required");
1318
- const result = await rt.manager.endCall(callId);
1319
- if (!result.success) throw new Error(result.error || "end failed");
1320
- return jsonResult({ success: true });
1048
+ sessionKey: normalizeOptionalString(rawParams.sessionKey),
1049
+ agentId,
1050
+ requesterSessionKey
1051
+ }));
1321
1052
  }
1053
+ case "continue_call": return jsonResult(await commands.continueCall(normalizeOptionalString(rawParams.callId), normalizeOptionalString(rawParams.message)));
1054
+ case "speak_to_user": return jsonResult(await commands.speak({
1055
+ callId: normalizeOptionalString(rawParams.callId),
1056
+ message: normalizeOptionalString(rawParams.message)
1057
+ }));
1058
+ case "send_dtmf": return jsonResult(await commands.sendDtmf(normalizeOptionalString(rawParams.callId), normalizeOptionalString(rawParams.digits)));
1059
+ case "end_call": return jsonResult(await commands.endCall(normalizeOptionalString(rawParams.callId)));
1322
1060
  case "get_status": {
1323
- const callId = normalizeOptionalString(rawParams.callId) ?? "";
1324
- if (!callId) throw new Error("callId required");
1325
- const call = await rt.manager.getCallFromMemoryOrStore(callId);
1326
- return jsonResult(call ? {
1327
- found: true,
1328
- call: toVoiceCallStatus(call)
1329
- } : { found: false });
1061
+ const callId = normalizeOptionalString(rawParams.callId);
1062
+ if (!callId) throw new VoiceCallCommandInputError("callId required");
1063
+ return jsonResult(await commands.status(callId));
1330
1064
  }
1331
1065
  }
1332
1066
  if ((rawParams.mode ?? "call") === "status") {
1333
1067
  const sid = normalizeOptionalString(rawParams.sid) ?? "";
1334
1068
  if (!sid) throw new Error("sid required for status");
1335
- const call = await rt.manager.getCallFromMemoryOrStore(sid);
1336
- return jsonResult(call ? {
1337
- found: true,
1338
- call: toVoiceCallStatus(call)
1339
- } : { found: false });
1069
+ return jsonResult(await commands.status(sid));
1340
1070
  }
1341
- const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
1342
- if (!to) throw new Error("to required for call");
1343
- const result = await rt.manager.initiateCall(to, normalizeOptionalString(rawParams.sessionKey), {
1071
+ return jsonResult(await commands.initiate({
1072
+ to: normalizeOptionalString(rawParams.to),
1344
1073
  dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
1345
1074
  message: normalizeOptionalString(rawParams.message),
1346
- ...agentId ? { agentId } : {},
1347
- ...normalizeOptionalString(rawParams.requesterSessionKey) ? { requesterSessionKey: normalizeOptionalString(rawParams.requesterSessionKey) } : {}
1348
- });
1349
- if (!result.success) throw new Error(result.error || "initiate failed");
1350
- return jsonResult({
1351
- callId: result.callId,
1352
- initiated: true
1353
- });
1075
+ sessionKey: normalizeOptionalString(rawParams.sessionKey),
1076
+ agentId,
1077
+ requesterSessionKey
1078
+ }, "to required for call"));
1354
1079
  } catch (err) {
1355
1080
  return jsonResult({ error: formatErrorMessage(err) });
1356
1081
  }
@@ -1368,35 +1093,41 @@ var voice_call_default = definePluginEntry({
1368
1093
  });
1369
1094
  api.registerService({
1370
1095
  id: "voicecall",
1371
- start: () => {
1096
+ start: (ctx) => {
1372
1097
  if (isCliOnlyProcess()) return;
1098
+ try {
1099
+ if (runtimeRegistration.generation.retired) {
1100
+ if (runtimeCoordinator.current !== runtimeRegistration) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
1101
+ runtimeRegistration.generation = { retired: false };
1102
+ }
1103
+ runtimeRegistration.generation.serviceHealth = ctx.serviceHealth;
1104
+ activateRuntimeGeneration(runtimeRegistration.generation);
1105
+ } catch (err) {
1106
+ ctx.serviceHealth?.reportFailure(err);
1107
+ api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
1108
+ return;
1109
+ }
1373
1110
  if (!config.enabled) return;
1374
1111
  if (!validation.valid) {
1375
- api.logger.warn(`[voice-call] Runtime not started; setup incomplete: ${validation.errors.join("; ")}`);
1112
+ const error = /* @__PURE__ */ new Error(`setup incomplete: ${validation.errors.join("; ")}`);
1113
+ ctx.serviceHealth?.reportFailure(error);
1114
+ api.logger.error(`[voice-call] Runtime not started: ${error.message}`);
1376
1115
  return;
1377
1116
  }
1378
- ensureRuntime().catch((err) => {
1117
+ const startingGeneration = runtimeRegistration.generation;
1118
+ ensureRuntime(startingGeneration).catch((err) => {
1119
+ if (err instanceof VoiceCallRuntimeLifecycleError) return;
1120
+ ctx.serviceHealth?.reportFailure(err);
1379
1121
  api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
1380
1122
  });
1381
1123
  },
1382
1124
  stop: async () => {
1383
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
1384
- await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
1385
- return;
1386
- }
1387
- const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
1388
- const runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
1389
- if (!runtime && !runtimePromise) return;
1390
- runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
1391
- runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
1392
- const stopPromise = (async () => {
1393
- await (runtime ?? await runtimePromise).stop();
1394
- })();
1395
- runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = stopPromise;
1125
+ const runtimeGeneration = runtimeRegistration.generation;
1126
+ runtimeGeneration.retired = true;
1396
1127
  try {
1397
- await stopPromise;
1128
+ await stopVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
1398
1129
  } finally {
1399
- if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] === stopPromise) runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = null;
1130
+ runtimeGeneration.serviceHealth = void 0;
1400
1131
  }
1401
1132
  }
1402
1133
  });