voicecc 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/README.md +48 -0
  3. package/bin/voicecc.js +39 -0
  4. package/dashboard/dist/assets/index-BXemFrMp.css +1 -0
  5. package/dashboard/dist/assets/index-dAYfRls7.js +11 -0
  6. package/dashboard/dist/audio-processor.js +126 -0
  7. package/dashboard/dist/index.html +13 -0
  8. package/dashboard/routes/auth.ts +119 -0
  9. package/dashboard/routes/browser-call.ts +87 -0
  10. package/dashboard/routes/claude-md.ts +50 -0
  11. package/dashboard/routes/conversations.ts +203 -0
  12. package/dashboard/routes/integrations.ts +154 -0
  13. package/dashboard/routes/mcp-servers.ts +198 -0
  14. package/dashboard/routes/settings.ts +64 -0
  15. package/dashboard/routes/tunnel.ts +66 -0
  16. package/dashboard/routes/twilio.ts +120 -0
  17. package/dashboard/routes/voice.ts +48 -0
  18. package/dashboard/routes/webrtc.ts +85 -0
  19. package/dashboard/server.ts +130 -0
  20. package/dashboard/tsconfig.json +13 -0
  21. package/init/CLAUDE.md +18 -0
  22. package/package.json +59 -0
  23. package/run.ts +68 -0
  24. package/scripts/postinstall.js +228 -0
  25. package/services/browser-call-manager.ts +106 -0
  26. package/services/device-pairing.ts +176 -0
  27. package/services/env.ts +88 -0
  28. package/services/tunnel.ts +204 -0
  29. package/services/twilio-manager.ts +126 -0
  30. package/sidecar/assets/startup.pcm +0 -0
  31. package/sidecar/audio-adapter.ts +60 -0
  32. package/sidecar/audio-capture.ts +220 -0
  33. package/sidecar/browser-audio-playback.test.ts +149 -0
  34. package/sidecar/browser-audio.ts +147 -0
  35. package/sidecar/browser-server.ts +331 -0
  36. package/sidecar/chime.test.ts +69 -0
  37. package/sidecar/chime.ts +54 -0
  38. package/sidecar/claude-session.ts +295 -0
  39. package/sidecar/endpointing.ts +163 -0
  40. package/sidecar/index.ts +83 -0
  41. package/sidecar/local-audio.ts +126 -0
  42. package/sidecar/mic-vpio +0 -0
  43. package/sidecar/mic-vpio.swift +484 -0
  44. package/sidecar/mock-tts-server-tagged.mjs +132 -0
  45. package/sidecar/narration.ts +204 -0
  46. package/sidecar/scripts/generate-startup-audio.py +79 -0
  47. package/sidecar/session-lock.ts +123 -0
  48. package/sidecar/sherpa-onnx-node.d.ts +4 -0
  49. package/sidecar/stt.ts +199 -0
  50. package/sidecar/tts-server.py +193 -0
  51. package/sidecar/tts.ts +481 -0
  52. package/sidecar/twilio-audio.ts +338 -0
  53. package/sidecar/twilio-server.ts +436 -0
  54. package/sidecar/types.ts +210 -0
  55. package/sidecar/vad.ts +101 -0
  56. package/sidecar/voice-loop-bugs.test.ts +522 -0
  57. package/sidecar/voice-session.ts +523 -0
  58. package/skills/voice/SKILL.md +26 -0
  59. package/tsconfig.json +22 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Browser call server process management.
3
+ *
4
+ * Manages the lifecycle of the browser-server child process.
5
+ * Analogous to twilio-manager.ts but simpler -- no TwiML app updates,
6
+ * no Twilio SDK dependency.
7
+ *
8
+ * Responsibilities:
9
+ * - Spawn browser-server.ts as a child process with DASHBOARD_PORT env var
10
+ * - Stop the server via SIGTERM
11
+ * - Report running status
12
+ */
13
+
14
+ import { spawn, ChildProcess } from "child_process";
15
+
16
+ // ============================================================================
17
+ // TYPES
18
+ // ============================================================================
19
+
20
+ /** Browser call server status for the dashboard UI */
21
+ export interface BrowserCallStatus {
22
+ /** Whether the browser-server process is alive */
23
+ running: boolean;
24
+ }
25
+
26
+ // ============================================================================
27
+ // STATE
28
+ // ============================================================================
29
+
30
+ /** Browser server child process handle */
31
+ let browserProcess: ChildProcess | null = null;
32
+
33
+ /** Whether the browser call server is running */
34
+ let browserRunning = false;
35
+
36
+ // ============================================================================
37
+ // MAIN HANDLERS
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Start the browser call server.
42
+ * Spawns browser-server.ts as a child process with DASHBOARD_PORT env var.
43
+ * Uses TWILIO_PORT from .env (default 8080).
44
+ *
45
+ * @param dashboardPort - The dashboard server port (for proxying)
46
+ * @throws Error if the server is already running
47
+ */
48
+ export async function startBrowserCallServer(dashboardPort: number): Promise<void> {
49
+ if (browserRunning) {
50
+ throw new Error("Browser call server is already running");
51
+ }
52
+
53
+ browserProcess = spawn("npx", ["tsx", "sidecar/browser-server.ts"], {
54
+ cwd: process.cwd(),
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ env: { ...process.env, DASHBOARD_PORT: String(dashboardPort) },
57
+ });
58
+
59
+ browserProcess.stdout?.on("data", (chunk: Buffer) => {
60
+ process.stdout.write(`[browser-server] ${chunk.toString()}`);
61
+ });
62
+ browserProcess.stderr?.on("data", (chunk: Buffer) => {
63
+ process.stderr.write(`[browser-server] ${chunk.toString()}`);
64
+ });
65
+
66
+ browserProcess.on("exit", (code) => {
67
+ if (browserRunning) {
68
+ console.error(`Browser call server exited unexpectedly (code ${code})`);
69
+ }
70
+ browserRunning = false;
71
+ browserProcess = null;
72
+ });
73
+
74
+ browserRunning = true;
75
+ console.log("Browser call server started.");
76
+ }
77
+
78
+ /**
79
+ * Stop the browser call server.
80
+ * Sends SIGTERM to the child process and clears state.
81
+ */
82
+ export function stopBrowserCallServer(): void {
83
+ if (browserProcess && !browserProcess.killed) {
84
+ browserProcess.kill("SIGTERM");
85
+ }
86
+ browserProcess = null;
87
+ browserRunning = false;
88
+ }
89
+
90
+ /**
91
+ * Get the status of the browser call server.
92
+ *
93
+ * @returns Status with running state
94
+ */
95
+ export function getBrowserCallStatus(): BrowserCallStatus {
96
+ return { running: browserRunning };
97
+ }
98
+
99
+ /**
100
+ * Check whether the browser call server process is currently alive.
101
+ *
102
+ * @returns True if the server is running
103
+ */
104
+ export function isBrowserCallRunning(): boolean {
105
+ return browserRunning;
106
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Device pairing and token management for WebRTC browser calling.
3
+ *
4
+ * Handles the pairing flow between the dashboard and remote devices:
5
+ * - Generate 6-digit pairing codes with 5-minute TTL
6
+ * - Validate codes and issue persistent device tokens
7
+ * - Persist device tokens to disk across restarts
8
+ * - Purge expired pairing codes automatically
9
+ */
10
+
11
+ import { readFile, writeFile } from "fs/promises";
12
+ import { join } from "path";
13
+ import { randomUUID } from "crypto";
14
+
15
+ // ============================================================================
16
+ // TYPES
17
+ // ============================================================================
18
+
19
+ /** Internal pairing code entry */
20
+ interface PairingCode {
21
+ expiresAt: number;
22
+ attempts: number;
23
+ }
24
+
25
+ /** Stored device token info */
26
+ interface DeviceTokenInfo {
27
+ pairedAt: number;
28
+ userAgent: string;
29
+ }
30
+
31
+ /** Result of generating a new pairing code */
32
+ export interface PairingResult {
33
+ code: string;
34
+ expiresAt: number;
35
+ }
36
+
37
+ /** Result of validating a pairing code */
38
+ export interface PairingValidation {
39
+ ok: boolean;
40
+ token?: string;
41
+ error?: string;
42
+ }
43
+
44
+ // ============================================================================
45
+ // CONSTANTS
46
+ // ============================================================================
47
+
48
+ const PAIRING_CODE_TTL_MS = 5 * 60 * 1000;
49
+ const PAIRING_MAX_ATTEMPTS = 5;
50
+ const DEVICE_TOKENS_PATH = join(process.cwd(), ".device-tokens.json");
51
+
52
+ // ============================================================================
53
+ // STATE
54
+ // ============================================================================
55
+
56
+ /** Active pairing codes: code -> { expiresAt, attempts } */
57
+ const pairingCodes = new Map<string, PairingCode>();
58
+
59
+ /** Paired device tokens: token -> { pairedAt, userAgent } */
60
+ const deviceTokens = new Map<string, DeviceTokenInfo>();
61
+
62
+ // Purge expired pairing codes every 60s
63
+ setInterval(() => {
64
+ const now = Date.now();
65
+ for (const [code, data] of pairingCodes) {
66
+ if (now > data.expiresAt) pairingCodes.delete(code);
67
+ }
68
+ }, 60_000);
69
+
70
+ // ============================================================================
71
+ // MAIN HANDLERS
72
+ // ============================================================================
73
+
74
+ /**
75
+ * Generate a 6-digit pairing code with a 5-minute TTL.
76
+ *
77
+ * @returns The generated code and its expiration timestamp
78
+ */
79
+ export function generatePairingCode(): PairingResult {
80
+ const code = String(Math.floor(100000 + Math.random() * 900000));
81
+ const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
82
+ pairingCodes.set(code, { expiresAt, attempts: 0 });
83
+ return { code, expiresAt };
84
+ }
85
+
86
+ /**
87
+ * Validate a pairing code and issue a device token on success.
88
+ * Enforces max attempts and single-use consumption.
89
+ *
90
+ * @param code - The 6-digit pairing code to validate
91
+ * @param userAgent - The device's user-agent string
92
+ * @returns Validation result with token on success or error on failure
93
+ */
94
+ export function validateAndConsumeCode(code: string, userAgent: string): PairingValidation {
95
+ const entry = pairingCodes.get(code);
96
+
97
+ if (!entry) {
98
+ return { ok: false, error: "Invalid pairing code" };
99
+ }
100
+
101
+ if (Date.now() > entry.expiresAt) {
102
+ pairingCodes.delete(code);
103
+ return { ok: false, error: "Pairing code expired" };
104
+ }
105
+
106
+ entry.attempts++;
107
+ if (entry.attempts > PAIRING_MAX_ATTEMPTS) {
108
+ pairingCodes.delete(code);
109
+ return { ok: false, error: "Too many attempts, code invalidated" };
110
+ }
111
+
112
+ // Code is valid -- delete it (single-use) and issue a device token
113
+ pairingCodes.delete(code);
114
+ const token = randomUUID();
115
+ deviceTokens.set(token, { pairedAt: Date.now(), userAgent });
116
+ saveDeviceTokens().catch(() => {});
117
+
118
+ return { ok: true, token };
119
+ }
120
+
121
+ /**
122
+ * Check if a device token exists in the store.
123
+ *
124
+ * @param token - The device token to validate
125
+ * @returns True if the token is valid
126
+ */
127
+ /**
128
+ * Check if a pairing code is still active (not yet consumed or expired).
129
+ *
130
+ * @param code - The 6-digit pairing code
131
+ * @returns True if the code is still waiting to be used
132
+ */
133
+ export function isPairingCodeActive(code: string): boolean {
134
+ const entry = pairingCodes.get(code);
135
+ if (!entry) return false;
136
+ if (Date.now() > entry.expiresAt) {
137
+ pairingCodes.delete(code);
138
+ return false;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ export function isValidDeviceToken(token: string): boolean {
144
+ return deviceTokens.has(token);
145
+ }
146
+
147
+ /**
148
+ * Load persisted device tokens from disk on startup.
149
+ * Call this before mounting routes.
150
+ */
151
+ export async function loadDeviceTokens(): Promise<void> {
152
+ try {
153
+ const data = JSON.parse(await readFile(DEVICE_TOKENS_PATH, "utf-8"));
154
+ for (const [token, info] of Object.entries(data)) {
155
+ deviceTokens.set(token, info as DeviceTokenInfo);
156
+ }
157
+ } catch {
158
+ // File doesn't exist or is invalid -- start fresh
159
+ }
160
+ }
161
+
162
+ // ============================================================================
163
+ // HELPER FUNCTIONS
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Persist device tokens to disk.
168
+ * Called internally by validateAndConsumeCode on success.
169
+ */
170
+ async function saveDeviceTokens(): Promise<void> {
171
+ const data: Record<string, DeviceTokenInfo> = {};
172
+ for (const [token, info] of deviceTokens) {
173
+ data[token] = info;
174
+ }
175
+ await writeFile(DEVICE_TOKENS_PATH, JSON.stringify(data), "utf-8");
176
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Environment file (.env) read/write service.
3
+ *
4
+ * Shared utility for all services that need to read or write .env configuration:
5
+ * - Parse raw .env content into key-value records
6
+ * - Read .env from disk with a configurable path
7
+ * - Write a single key or full record back to disk
8
+ */
9
+
10
+ import { readFile, writeFile } from "fs/promises";
11
+ import { join } from "path";
12
+
13
+ // ============================================================================
14
+ // TYPES
15
+ // ============================================================================
16
+
17
+ /** Key-value record representing parsed .env contents */
18
+ export type EnvRecord = Record<string, string>;
19
+
20
+ // ============================================================================
21
+ // MAIN HANDLERS
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Read and parse a .env file from disk.
26
+ * Returns an empty record if the file does not exist.
27
+ *
28
+ * @param envPath - Absolute path to the .env file. Defaults to process.cwd()/.env
29
+ * @returns Parsed key-value pairs from the .env file
30
+ */
31
+ export async function readEnv(envPath?: string): Promise<EnvRecord> {
32
+ const filePath = envPath ?? join(process.cwd(), ".env");
33
+ const content = await readFile(filePath, "utf-8").catch(() => "");
34
+ return parseEnvFile(content);
35
+ }
36
+
37
+ /**
38
+ * Update a single key in the .env file, preserving all other values.
39
+ * Creates the file if it does not exist.
40
+ *
41
+ * @param key - The env variable name to set
42
+ * @param value - The value to write
43
+ * @param envPath - Absolute path to the .env file. Defaults to process.cwd()/.env
44
+ */
45
+ export async function writeEnvKey(key: string, value: string, envPath?: string): Promise<void> {
46
+ const settings = await readEnv(envPath);
47
+ settings[key] = value;
48
+ await writeEnvFile(settings, envPath);
49
+ }
50
+
51
+ /**
52
+ * Write a full key-value record to a .env file.
53
+ * Overwrites the entire file contents. Each entry becomes a KEY=VALUE line.
54
+ *
55
+ * @param settings - Key-value pairs to write
56
+ * @param envPath - Absolute path to the .env file. Defaults to process.cwd()/.env
57
+ */
58
+ export async function writeEnvFile(settings: EnvRecord, envPath?: string): Promise<void> {
59
+ const filePath = envPath ?? join(process.cwd(), ".env");
60
+ const lines = Object.entries(settings).map(([k, v]) => `${k}=${v}`);
61
+ await writeFile(filePath, lines.join("\n") + "\n", "utf-8");
62
+ }
63
+
64
+ // ============================================================================
65
+ // HELPER FUNCTIONS
66
+ // ============================================================================
67
+
68
+ /**
69
+ * Parse a .env file string into a key-value record.
70
+ * Handles lines in the format KEY=VALUE, ignores empty lines and comments.
71
+ * Keeps empty values (KEY= produces { KEY: "" }). Does NOT strip quotes.
72
+ *
73
+ * @param content - Raw .env file content
74
+ * @returns Parsed key-value pairs
75
+ */
76
+ export function parseEnvFile(content: string): EnvRecord {
77
+ const result: EnvRecord = {};
78
+ for (const line of content.split("\n")) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed || trimmed.startsWith("#")) continue;
81
+ const eqIndex = trimmed.indexOf("=");
82
+ if (eqIndex === -1) continue;
83
+ const key = trimmed.slice(0, eqIndex).trim();
84
+ const value = trimmed.slice(eqIndex + 1).trim();
85
+ result[key] = value;
86
+ }
87
+ return result;
88
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Cloudflare quick tunnel process lifecycle management.
3
+ *
4
+ * Manages spawning and stopping the cloudflared tunnel process:
5
+ * - Start cloudflared on a given port and parse the public HTTPS URL from stderr
6
+ * - Stop cloudflared and clear state
7
+ * - Check if cloudflared is installed
8
+ */
9
+
10
+ import { spawn, execFile, ChildProcess } from "child_process";
11
+ import { writeEnvKey } from "./env.js";
12
+
13
+ // ============================================================================
14
+ // CONSTANTS
15
+ // ============================================================================
16
+
17
+ /** Timeout for waiting for the tunnel URL to appear in stderr */
18
+ const TUNNEL_URL_TIMEOUT_MS = 30000;
19
+
20
+ // ============================================================================
21
+ // STATE
22
+ // ============================================================================
23
+
24
+ /** cloudflared child process handle */
25
+ let tunnelProcess: ChildProcess | null = null;
26
+
27
+ /** Current public tunnel URL */
28
+ let tunnelUrl: string | null = null;
29
+
30
+ /** Timestamp when tunnel URL was obtained */
31
+ let tunnelStartedAt: number | null = null;
32
+
33
+ // ============================================================================
34
+ // MAIN HANDLERS
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Start a Cloudflare quick tunnel on the given port.
39
+ * Parses the public HTTPS URL from cloudflared's stderr output,
40
+ * writes it to .env as TWILIO_WEBHOOK_URL, and returns it.
41
+ *
42
+ * @param port - Local port to tunnel
43
+ * @returns The public HTTPS URL
44
+ */
45
+ export async function startTunnel(port: number): Promise<string> {
46
+ if (tunnelProcess) {
47
+ throw new Error("Tunnel is already running");
48
+ }
49
+
50
+ let tunnelStderr = "";
51
+
52
+ tunnelProcess = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`, "--protocol", "http2"], {
53
+ cwd: process.cwd(),
54
+ stdio: ["ignore", "ignore", "pipe"],
55
+ });
56
+
57
+ tunnelProcess.stderr?.on("data", (chunk: Buffer) => {
58
+ const text = chunk.toString();
59
+ tunnelStderr += text;
60
+ for (const line of text.split("\n")) {
61
+ if (line.trim() && (line.includes("WRN") || line.includes("ERR"))) {
62
+ console.log(`[tunnel] ${line}`);
63
+ }
64
+ }
65
+ });
66
+
67
+ await new Promise<void>((resolve, reject) => {
68
+ tunnelProcess!.on("error", (err: NodeJS.ErrnoException) => {
69
+ tunnelProcess = null;
70
+ if (err.code === "ENOENT") {
71
+ reject(new Error(
72
+ "cloudflared is not installed. Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ or: brew install cloudflared"
73
+ ));
74
+ } else {
75
+ reject(new Error(`Failed to start cloudflared: ${err.message}`));
76
+ }
77
+ });
78
+ tunnelProcess!.on("spawn", () => resolve());
79
+ });
80
+
81
+ tunnelProcess.on("exit", (code) => {
82
+ console.log(`cloudflared exited (code ${code})`);
83
+ tunnelProcess = null;
84
+ tunnelUrl = null;
85
+ });
86
+
87
+ // Parse the tunnel URL from stderr
88
+ const url = await parseTunnelUrl(() => {
89
+ if (tunnelProcess === null || tunnelProcess.exitCode !== null) {
90
+ return tunnelStderr.trim() || "cloudflared exited immediately. Run 'cloudflared tunnel --url http://localhost:8080' manually to see the error.";
91
+ }
92
+ return null;
93
+ });
94
+
95
+ tunnelUrl = url;
96
+ tunnelStartedAt = Date.now();
97
+ await writeEnvKey("TWILIO_WEBHOOK_URL", url);
98
+ console.log(`Tunnel URL: ${url}`);
99
+ return url;
100
+ }
101
+
102
+ /**
103
+ * Stop the tunnel and clear state.
104
+ */
105
+ export function stopTunnel(): void {
106
+ if (tunnelProcess && !tunnelProcess.killed) {
107
+ tunnelProcess.kill("SIGTERM");
108
+ }
109
+ tunnelProcess = null;
110
+ tunnelUrl = null;
111
+ tunnelStartedAt = null;
112
+ }
113
+
114
+ /**
115
+ * Return the current public tunnel URL, or null if not running.
116
+ *
117
+ * @returns The public HTTPS URL or null
118
+ */
119
+ export function getTunnelUrl(): string | null {
120
+ return tunnelUrl;
121
+ }
122
+
123
+ /**
124
+ * Return the timestamp when the tunnel URL was obtained, or null.
125
+ *
126
+ * @returns Unix ms timestamp or null
127
+ */
128
+ export function getTunnelStartedAt(): number | null {
129
+ return tunnelStartedAt;
130
+ }
131
+
132
+ /**
133
+ * Check whether the tunnel process is currently alive.
134
+ *
135
+ * @returns True if tunnel is running
136
+ */
137
+ export function isTunnelRunning(): boolean {
138
+ return tunnelProcess !== null && !tunnelProcess.killed;
139
+ }
140
+
141
+ /**
142
+ * Check if cloudflared is installed by running `cloudflared version`.
143
+ *
144
+ * @returns True if cloudflared is installed
145
+ */
146
+ export async function checkCloudflaredInstalled(): Promise<boolean> {
147
+ return new Promise<boolean>((resolve) => {
148
+ try {
149
+ const child = execFile("cloudflared", ["version"], (err) => resolve(!err));
150
+ child.on("error", () => {}); // Suppress duplicate error event
151
+ } catch {
152
+ resolve(false);
153
+ }
154
+ });
155
+ }
156
+
157
+ // ============================================================================
158
+ // HELPER FUNCTIONS
159
+ // ============================================================================
160
+
161
+ /**
162
+ * Wait for and parse the tunnel URL from cloudflared's stderr output.
163
+ * cloudflared prints the URL as: https://<random>.trycloudflare.com
164
+ *
165
+ * @param checkEarlyExit - Returns an error message if cloudflared exited, null otherwise
166
+ * @returns The public HTTPS URL
167
+ */
168
+ async function parseTunnelUrl(checkEarlyExit: () => string | null): Promise<string> {
169
+ const deadline = Date.now() + TUNNEL_URL_TIMEOUT_MS;
170
+
171
+ return new Promise<string>((resolve, reject) => {
172
+ // Listen for the URL in stderr output
173
+ const onData = (chunk: Buffer) => {
174
+ const text = chunk.toString();
175
+ const match = text.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
176
+ if (match) {
177
+ tunnelProcess?.stderr?.off("data", onData);
178
+ clearInterval(checkInterval);
179
+ clearTimeout(timeout);
180
+ resolve(match[0]);
181
+ }
182
+ };
183
+
184
+ tunnelProcess?.stderr?.on("data", onData);
185
+
186
+ // Periodically check for early exit
187
+ const checkInterval = setInterval(() => {
188
+ const earlyExitError = checkEarlyExit();
189
+ if (earlyExitError) {
190
+ tunnelProcess?.stderr?.off("data", onData);
191
+ clearInterval(checkInterval);
192
+ clearTimeout(timeout);
193
+ reject(new Error(earlyExitError));
194
+ }
195
+ }, 500);
196
+
197
+ // Timeout
198
+ const timeout = setTimeout(() => {
199
+ tunnelProcess?.stderr?.off("data", onData);
200
+ clearInterval(checkInterval);
201
+ reject(new Error("Timed out waiting for tunnel URL"));
202
+ }, TUNNEL_URL_TIMEOUT_MS);
203
+ });
204
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Twilio voice server process management.
3
+ *
4
+ * Manages the lifecycle of the twilio-server child process:
5
+ * - Start the server with dashboard port and optional tunnel URL
6
+ * - Stop the server
7
+ * - Report running status
8
+ */
9
+
10
+ import { spawn, ChildProcess } from "child_process";
11
+ import { readEnv } from "./env.js";
12
+ import twilioSdk from "twilio";
13
+
14
+ // ============================================================================
15
+ // TYPES
16
+ // ============================================================================
17
+
18
+ /** Twilio server status for the dashboard UI */
19
+ export interface TwilioStatus {
20
+ running: boolean;
21
+ }
22
+
23
+ // ============================================================================
24
+ // STATE
25
+ // ============================================================================
26
+
27
+ /** Twilio server child process handle */
28
+ let twilioProcess: ChildProcess | null = null;
29
+
30
+ /** Whether the Twilio voice server is running */
31
+ let twilioRunning = false;
32
+
33
+ // ============================================================================
34
+ // MAIN HANDLERS
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Start the Twilio voice server.
39
+ * Reads .env for TWILIO_AUTH_TOKEN. If tunnelUrl and TwiML app SID exist,
40
+ * updates the TwiML app voice URL via Twilio SDK.
41
+ * Spawns twilio-server.ts as a child process with DASHBOARD_PORT env var.
42
+ *
43
+ * @param dashboardPort - The dashboard server port (for proxying)
44
+ * @param tunnelUrl - Optional tunnel public URL for webhook configuration
45
+ */
46
+ export async function startTwilioServer(dashboardPort: number, tunnelUrl?: string): Promise<void> {
47
+ if (twilioRunning) {
48
+ throw new Error("Twilio server is already running");
49
+ }
50
+
51
+ const envVars = await readEnv();
52
+
53
+ if (!envVars.TWILIO_AUTH_TOKEN) {
54
+ throw new Error("TWILIO_AUTH_TOKEN is not set in .env");
55
+ }
56
+
57
+ // Update TwiML App voice URL if configured
58
+ const twimlAppSid = envVars.TWILIO_TWIML_APP_SID;
59
+ const accountSid = envVars.TWILIO_ACCOUNT_SID;
60
+ if (tunnelUrl && twimlAppSid && accountSid && envVars.TWILIO_AUTH_TOKEN) {
61
+ try {
62
+ const client = twilioSdk(accountSid, envVars.TWILIO_AUTH_TOKEN);
63
+ await client.applications(twimlAppSid).update({
64
+ voiceUrl: `${tunnelUrl}/twilio/incoming-call`,
65
+ voiceMethod: "POST",
66
+ });
67
+ console.log(`Updated TwiML App voice URL to ${tunnelUrl}/twilio/incoming-call`);
68
+ } catch (err) {
69
+ console.error(`Failed to update TwiML App voice URL: ${err}`);
70
+ }
71
+ }
72
+
73
+ // Start the Twilio server (pass dashboard port so it can proxy non-Twilio requests)
74
+ twilioProcess = spawn("npx", ["tsx", "sidecar/twilio-server.ts"], {
75
+ cwd: process.cwd(),
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ env: { ...process.env, DASHBOARD_PORT: String(dashboardPort) },
78
+ });
79
+
80
+ twilioProcess.stdout?.on("data", (chunk: Buffer) => {
81
+ process.stdout.write(`[twilio-server] ${chunk.toString()}`);
82
+ });
83
+ twilioProcess.stderr?.on("data", (chunk: Buffer) => {
84
+ process.stderr.write(`[twilio-server] ${chunk.toString()}`);
85
+ });
86
+
87
+ twilioProcess.on("exit", (code) => {
88
+ if (twilioRunning) {
89
+ console.error(`Twilio server exited unexpectedly (code ${code})`);
90
+ }
91
+ twilioRunning = false;
92
+ twilioProcess = null;
93
+ });
94
+
95
+ twilioRunning = true;
96
+ console.log("Twilio server started.");
97
+ }
98
+
99
+ /**
100
+ * Stop the Twilio voice server.
101
+ */
102
+ export function stopTwilioServer(): void {
103
+ if (twilioProcess && !twilioProcess.killed) {
104
+ twilioProcess.kill("SIGTERM");
105
+ }
106
+ twilioProcess = null;
107
+ twilioRunning = false;
108
+ }
109
+
110
+ /**
111
+ * Get the status of the Twilio server.
112
+ *
113
+ * @returns Status with running state
114
+ */
115
+ export async function getStatus(): Promise<TwilioStatus> {
116
+ return { running: twilioRunning };
117
+ }
118
+
119
+ /**
120
+ * Check whether the Twilio server process is currently alive.
121
+ *
122
+ * @returns True if the server is running
123
+ */
124
+ export function isRunning(): boolean {
125
+ return twilioRunning;
126
+ }
Binary file