voicecc 1.2.11 → 1.2.13
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/dashboard/dist/assets/index-DbjqXBdo.js +28 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/routes/agents.ts +5 -5
- package/dashboard/routes/twilio.ts +5 -7
- package/package.json +1 -1
- package/server/index.ts +67 -1
- package/server/services/twilio-manager.ts +21 -11
- package/voice-server/config.py +2 -0
- package/voice-server/heartbeat.py +9 -5
- package/dashboard/dist/assets/index-DCeOdulF.js +0 -28
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Claude Voice</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DbjqXBdo.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-DeN1WDo9.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -192,18 +192,18 @@ export function agentsRoutes(): Hono {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
// Place the actual Twilio call
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return c.json({ error: "No Twilio phone numbers found on this account" }, 400);
|
|
195
|
+
const fromNumber = envVars.TWILIO_PHONE_NUMBER;
|
|
196
|
+
if (!fromNumber) {
|
|
197
|
+
return c.json({ error: "No Twilio phone number selected. Select one in Twilio settings." }, 400);
|
|
199
198
|
}
|
|
200
199
|
|
|
200
|
+
const client = twilioSdk(accountSid, authToken);
|
|
201
201
|
const tunnelHost = tunnelUrl.replace(/^https?:\/\//, "");
|
|
202
202
|
const twiml = `<Response><Connect><Stream url="wss://${tunnelHost}/media/${token}?agentId=${id}" /></Connect></Response>`;
|
|
203
203
|
|
|
204
204
|
const call = await client.calls.create({
|
|
205
205
|
to: userPhone,
|
|
206
|
-
from:
|
|
206
|
+
from: fromNumber,
|
|
207
207
|
twiml,
|
|
208
208
|
});
|
|
209
209
|
|
|
@@ -134,17 +134,15 @@ export function twilioRoutes(): Hono {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
try {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const numbers = await client.incomingPhoneNumbers.list({ limit: 1 });
|
|
141
|
-
if (numbers.length === 0) {
|
|
142
|
-
return c.json({ error: "No Twilio phone numbers found on this account" }, 400);
|
|
137
|
+
const fromNumber = envVars.TWILIO_PHONE_NUMBER;
|
|
138
|
+
if (!fromNumber) {
|
|
139
|
+
return c.json({ error: "No Twilio phone number selected. Select one in step 2." }, 400);
|
|
143
140
|
}
|
|
144
141
|
|
|
142
|
+
const client = twilioSdk(accountSid, authToken);
|
|
145
143
|
const call = await client.calls.create({
|
|
146
144
|
to,
|
|
147
|
-
from:
|
|
145
|
+
from: fromNumber,
|
|
148
146
|
twiml: '<Response><Say>This is a test call from your voice assistant. If you can hear this, your Twilio setup is working correctly. Goodbye!</Say></Response>',
|
|
149
147
|
});
|
|
150
148
|
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -43,6 +43,18 @@ const VOICE_SERVER_DIR = join(import.meta.dirname ?? ".", "..", "voice-server");
|
|
|
43
43
|
/** Reference to the Python voice server child process */
|
|
44
44
|
let pythonProcess: ChildProcess | null = null;
|
|
45
45
|
|
|
46
|
+
/** Maximum number of automatic restart attempts after an unexpected crash */
|
|
47
|
+
const PYTHON_MAX_RESTART_ATTEMPTS = 5;
|
|
48
|
+
|
|
49
|
+
/** Delay before attempting a restart (ms) */
|
|
50
|
+
const PYTHON_RESTART_DELAY_MS = 3_000;
|
|
51
|
+
|
|
52
|
+
/** Number of consecutive restart attempts since last successful start */
|
|
53
|
+
let pythonRestartAttempts = 0;
|
|
54
|
+
|
|
55
|
+
/** Whether the Python server was intentionally stopped (skip auto-restart) */
|
|
56
|
+
let pythonManuallyStopped = false;
|
|
57
|
+
|
|
46
58
|
/**
|
|
47
59
|
* Start the Python voice server as a child process.
|
|
48
60
|
* Waits for the health endpoint to respond before returning.
|
|
@@ -63,6 +75,7 @@ async function startPythonVoiceServer(): Promise<void> {
|
|
|
63
75
|
pythonProcess.on("exit", (code) => {
|
|
64
76
|
console.error(`Python voice server exited with code ${code}`);
|
|
65
77
|
pythonProcess = null;
|
|
78
|
+
schedulePythonRestart();
|
|
66
79
|
});
|
|
67
80
|
|
|
68
81
|
// Wait for health endpoint (up to 15s)
|
|
@@ -72,6 +85,7 @@ async function startPythonVoiceServer(): Promise<void> {
|
|
|
72
85
|
const res = await fetch(`${VOICE_SERVER_API_URL}/health`);
|
|
73
86
|
if (res.ok) {
|
|
74
87
|
console.log("Python voice server is ready");
|
|
88
|
+
pythonRestartAttempts = 0;
|
|
75
89
|
return;
|
|
76
90
|
}
|
|
77
91
|
} catch {
|
|
@@ -83,15 +97,67 @@ async function startPythonVoiceServer(): Promise<void> {
|
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
/**
|
|
86
|
-
* Stop the Python voice server child process.
|
|
100
|
+
* Stop the Python voice server child process. Prevents auto-restart.
|
|
87
101
|
*/
|
|
88
102
|
function stopPythonVoiceServer(): void {
|
|
103
|
+
pythonManuallyStopped = true;
|
|
89
104
|
if (pythonProcess) {
|
|
90
105
|
pythonProcess.kill("SIGTERM");
|
|
91
106
|
pythonProcess = null;
|
|
92
107
|
}
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Schedule an automatic restart of the Python voice server after an unexpected exit.
|
|
112
|
+
* Skips restart if manually stopped or max attempts exceeded.
|
|
113
|
+
*/
|
|
114
|
+
function schedulePythonRestart(): void {
|
|
115
|
+
if (pythonManuallyStopped) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
pythonRestartAttempts++;
|
|
120
|
+
|
|
121
|
+
if (pythonRestartAttempts > PYTHON_MAX_RESTART_ATTEMPTS) {
|
|
122
|
+
console.error(`[voice-server] Giving up after ${PYTHON_MAX_RESTART_ATTEMPTS} restart attempts`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(
|
|
127
|
+
`[voice-server] Restarting in ${PYTHON_RESTART_DELAY_MS / 1000}s ` +
|
|
128
|
+
`(attempt ${pythonRestartAttempts}/${PYTHON_MAX_RESTART_ATTEMPTS})...`
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
setTimeout(async () => {
|
|
132
|
+
if (pythonManuallyStopped || pythonProcess) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await startPythonVoiceServer();
|
|
138
|
+
pythonRestartAttempts = 0;
|
|
139
|
+
console.log("[voice-server] Restarted successfully");
|
|
140
|
+
|
|
141
|
+
// Re-notify of tunnel URL if tunnel is running
|
|
142
|
+
const currentTunnelUrl = getTunnelUrl();
|
|
143
|
+
if (currentTunnelUrl) {
|
|
144
|
+
try {
|
|
145
|
+
await fetch(`${VOICE_SERVER_API_URL}/config/tunnel-url`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ url: currentTunnelUrl }),
|
|
149
|
+
});
|
|
150
|
+
} catch {
|
|
151
|
+
console.warn("[voice-server] Failed to re-notify tunnel URL after restart");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(`[voice-server] Restart failed: ${err}`);
|
|
156
|
+
schedulePythonRestart();
|
|
157
|
+
}
|
|
158
|
+
}, PYTHON_RESTART_DELAY_MS);
|
|
159
|
+
}
|
|
160
|
+
|
|
95
161
|
// Use VOICECC_DIR env var if set (passed by CLI when dropping root privileges),
|
|
96
162
|
// otherwise fall back to ~/.voicecc.
|
|
97
163
|
const VOICECC_DIR = process.env.VOICECC_DIR ?? join(homedir(), ".voicecc");
|
|
@@ -70,26 +70,36 @@ export async function startTwilioServer(_dashboardPort: number, tunnelUrl?: stri
|
|
|
70
70
|
throw new Error("TWILIO_AUTH_TOKEN is not set in .env");
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
if (!envVars.TWILIO_PHONE_NUMBER) {
|
|
74
|
+
throw new Error("TWILIO_PHONE_NUMBER is not set in .env");
|
|
75
|
+
}
|
|
76
|
+
|
|
73
77
|
const accountSid = envVars.TWILIO_ACCOUNT_SID;
|
|
74
78
|
const webhookUrl = tunnelUrl ? `${tunnelUrl}/api/twilio/incoming-call` : null;
|
|
75
79
|
|
|
76
80
|
if (tunnelUrl && accountSid && envVars.TWILIO_AUTH_TOKEN) {
|
|
77
81
|
const client = twilioSdk(accountSid, envVars.TWILIO_AUTH_TOKEN);
|
|
78
82
|
|
|
79
|
-
// Update
|
|
83
|
+
// Update the selected phone number's webhook URL
|
|
84
|
+
const selectedNumber = envVars.TWILIO_PHONE_NUMBER;
|
|
80
85
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await client.incomingPhoneNumbers
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
if (selectedNumber) {
|
|
87
|
+
// Find the SID for the selected number and update only that one
|
|
88
|
+
const numbers = await client.incomingPhoneNumbers.list({ phoneNumber: selectedNumber });
|
|
89
|
+
if (numbers.length > 0) {
|
|
90
|
+
await client.incomingPhoneNumbers(numbers[0].sid).update({
|
|
91
|
+
voiceUrl: webhookUrl!,
|
|
92
|
+
voiceMethod: "POST",
|
|
93
|
+
});
|
|
94
|
+
console.log(`Updated webhook for ${selectedNumber} to ${webhookUrl}`);
|
|
95
|
+
} else {
|
|
96
|
+
console.error(`Selected phone number ${selectedNumber} not found on Twilio account`);
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
console.warn("No TWILIO_PHONE_NUMBER configured, skipping webhook setup");
|
|
90
100
|
}
|
|
91
101
|
} catch (err) {
|
|
92
|
-
console.error(`Failed to update phone number
|
|
102
|
+
console.error(`Failed to update phone number webhook: ${err}`);
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
|
package/voice-server/config.py
CHANGED
|
@@ -89,6 +89,7 @@ class VoiceServerConfig:
|
|
|
89
89
|
project_root: str
|
|
90
90
|
twilio_account_sid: str
|
|
91
91
|
twilio_auth_token: str
|
|
92
|
+
twilio_phone_number: str
|
|
92
93
|
user_phone_number: str
|
|
93
94
|
max_concurrent_sessions: int
|
|
94
95
|
|
|
@@ -127,6 +128,7 @@ def load_config() -> VoiceServerConfig:
|
|
|
127
128
|
project_root=PROJECT_ROOT,
|
|
128
129
|
twilio_account_sid=os.environ.get("TWILIO_ACCOUNT_SID", ""),
|
|
129
130
|
twilio_auth_token=os.environ.get("TWILIO_AUTH_TOKEN", ""),
|
|
131
|
+
twilio_phone_number=os.environ.get("TWILIO_PHONE_NUMBER", ""),
|
|
130
132
|
user_phone_number=os.environ.get("USER_PHONE_NUMBER", ""),
|
|
131
133
|
max_concurrent_sessions=int(
|
|
132
134
|
os.environ.get("MAX_CONCURRENT_SESSIONS") or DEFAULT_MAX_CONCURRENT_SESSIONS
|
|
@@ -17,6 +17,7 @@ Responsibilities:
|
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
|
+
import gc
|
|
20
21
|
import json
|
|
21
22
|
import logging
|
|
22
23
|
import os
|
|
@@ -289,6 +290,9 @@ async def check_single_agent(agent: Agent) -> HeartbeatResult:
|
|
|
289
290
|
pass
|
|
290
291
|
_in_flight_checks.discard(agent.id)
|
|
291
292
|
|
|
293
|
+
# Force garbage collection to reclaim memory from the Claude subprocess
|
|
294
|
+
gc.collect()
|
|
295
|
+
|
|
292
296
|
|
|
293
297
|
async def _run_heartbeat_session(
|
|
294
298
|
agent: Agent, timeout_s: float
|
|
@@ -410,14 +414,14 @@ async def initiate_agent_call(agent: Agent, client: ClaudeSDKClient) -> str:
|
|
|
410
414
|
# Schedule cleanup for unanswered calls
|
|
411
415
|
asyncio.create_task(_cleanup_pending_call(token))
|
|
412
416
|
|
|
413
|
-
# Get
|
|
417
|
+
# Get the configured Twilio phone number
|
|
414
418
|
from twilio.rest import Client as TwilioClient
|
|
415
419
|
|
|
420
|
+
from_number: str = _config.twilio_phone_number
|
|
421
|
+
if not from_number:
|
|
422
|
+
raise RuntimeError("No TWILIO_PHONE_NUMBER configured")
|
|
423
|
+
|
|
416
424
|
twilio_client = TwilioClient(_config.twilio_account_sid, _config.twilio_auth_token)
|
|
417
|
-
numbers = twilio_client.incoming_phone_numbers.list(limit=1)
|
|
418
|
-
if not numbers:
|
|
419
|
-
raise RuntimeError("No Twilio phone numbers found on the account")
|
|
420
|
-
from_number: str = numbers[0].phone_number or ""
|
|
421
425
|
|
|
422
426
|
# Build TwiML with WebSocket stream URL
|
|
423
427
|
tunnel_host = tunnel_url.replace("https://", "").replace("http://", "")
|