claude-phone-local 2.0.7 → 2.0.9

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.
@@ -118,7 +118,7 @@ console.log('[STARTUP] API keys loaded:', apiKeys.join(', '));
118
118
  const sessions = new Map();
119
119
 
120
120
  // Model selection - Sonnet for balanced speed/quality
121
- const CLAUDE_MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
121
+ const CLAUDE_MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-5';
122
122
 
123
123
  function parseClaudeStdout(stdout) {
124
124
  // Claude Code CLI may output JSONL; when it does, extract the `result` message.
@@ -382,8 +382,19 @@ async function setupVoiceServer(config) {
382
382
  // Step 5: Server Configuration (IP only, no API port)
383
383
  console.log(chalk.bold('\n⚙️ Server Configuration'));
384
384
  const localIp = getLocalIP();
385
- const serverAnswers = await inquirer.prompt([
385
+ const { ipMode } = await inquirer.prompt([
386
386
  {
387
+ type: 'confirm',
388
+ name: 'ipMode',
389
+ message: `Auto-detect the LAN IP on every "claude-phone start" (currently ${localIp})? ` +
390
+ 'Recommended if this machine moves networks (laptop, DHCP) - answering ' +
391
+ 'no locks in a fixed IP that goes stale the next time it changes.',
392
+ default: config.server.externalIp === 'auto' || config.server.externalIp === undefined
393
+ }
394
+ ]);
395
+
396
+ const serverAnswers = await inquirer.prompt([
397
+ ...(ipMode ? [] : [{
387
398
  type: 'input',
388
399
  name: 'externalIp',
389
400
  message: 'Server LAN IP (for RTP audio):',
@@ -397,7 +408,7 @@ async function setupVoiceServer(config) {
397
408
  }
398
409
  return true;
399
410
  }
400
- },
411
+ }]),
401
412
  {
402
413
  type: 'input',
403
414
  name: 'httpPort',
@@ -413,7 +424,7 @@ async function setupVoiceServer(config) {
413
424
  }
414
425
  ]);
415
426
 
416
- config.server.externalIp = serverAnswers.externalIp;
427
+ config.server.externalIp = ipMode ? 'auto' : serverAnswers.externalIp;
417
428
  config.server.httpPort = parseInt(serverAnswers.httpPort, 10);
418
429
 
419
430
  return config;
@@ -5,8 +5,8 @@ import path from 'path';
5
5
  import { loadConfig, configExists, getInstallationType } from '../config.js';
6
6
  import { checkDocker, writeDockerConfig, startContainers } from '../docker.js';
7
7
  import { startServer, isServerRunning } from '../process-manager.js';
8
- import { isClaudeInstalled, sleep } from '../utils.js';
9
- import { checkClaudeApiServer } from '../network.js';
8
+ import { isClaudeInstalled } from '../utils.js';
9
+ import { checkClaudeApiServer, waitForVoiceAppReady } from '../network.js';
10
10
  import { runPrereqChecks } from '../prereqs.js';
11
11
 
12
12
  /**
@@ -184,10 +184,16 @@ async function startVoiceServer(config, isPiMode) {
184
184
  throw error;
185
185
  }
186
186
 
187
- // Wait a bit for containers to initialize
188
- spinner.start('Waiting for containers to initialize...');
189
- await sleep(3000);
190
- spinner.succeed('Containers initialized');
187
+ // Wait for FreeSWITCH to actually be ready to accept calls, not just for
188
+ // the container process to be up. A call landing in that gap gets a SIP
189
+ // 503 and falls through to 3CX's generic voicemail prompt.
190
+ spinner.start('Waiting for voice services to be ready...');
191
+ const readiness = await waitForVoiceAppReady(`http://localhost:${config.server.httpPort}`);
192
+ if (readiness.ready) {
193
+ spinner.succeed('Voice services ready');
194
+ } else {
195
+ spinner.warn('Voice services did not report ready in time - calls may briefly fail while it finishes starting');
196
+ }
191
197
 
192
198
  // Success
193
199
  console.log(chalk.bold.green('\n✓ Voice server running!\n'));
@@ -307,10 +313,16 @@ async function startBoth(config, isPiMode) {
307
313
  throw error;
308
314
  }
309
315
 
310
- // Wait a bit for containers to initialize
311
- spinner.start('Waiting for containers to initialize...');
312
- await sleep(3000);
313
- spinner.succeed('Containers initialized');
316
+ // Wait for FreeSWITCH to actually be ready to accept calls, not just for
317
+ // the container process to be up. A call landing in that gap gets a SIP
318
+ // 503 and falls through to 3CX's generic voicemail prompt.
319
+ spinner.start('Waiting for voice services to be ready...');
320
+ const readiness = await waitForVoiceAppReady(`http://localhost:${config.server.httpPort}`);
321
+ if (readiness.ready) {
322
+ spinner.succeed('Voice services ready');
323
+ } else {
324
+ spinner.warn('Voice services did not report ready in time - calls may briefly fail while it finishes starting');
325
+ }
314
326
 
315
327
  // Start claude-api-server (only in standard mode - Pi mode uses remote API server)
316
328
  if (!isPiMode) {
@@ -107,3 +107,42 @@ export async function checkClaudeApiServer(url) {
107
107
  }
108
108
  });
109
109
  }
110
+
111
+ /**
112
+ * Poll voice-app's /health until it reports drachtio + FreeSWITCH both
113
+ * connected, or the timeout elapses. Fixes `claude-phone start` reporting
114
+ * "All services running!" while FreeSWITCH is still booting - a call landing
115
+ * in that gap gets a SIP 503 and falls through to the 3CX generic voicemail
116
+ * prompt instead of ever reaching the app.
117
+ * @param {string} url - voice-app base URL (e.g. http://localhost:3000)
118
+ * @param {object} [opts]
119
+ * @param {number} [opts.timeoutMs=30000] - Give up after this long
120
+ * @param {number} [opts.intervalMs=1000] - Poll interval
121
+ * @returns {Promise<{ready: boolean, timedOut: boolean}>}
122
+ */
123
+ export async function waitForVoiceAppReady(url, { timeoutMs = 30000, intervalMs = 1000 } = {}) {
124
+ const deadline = Date.now() + timeoutMs;
125
+
126
+ while (Date.now() < deadline) {
127
+ const ready = await new Promise((resolve) => {
128
+ const req = http.get(url + '/health', { timeout: 3000 }, (res) => {
129
+ let body = '';
130
+ res.on('data', (chunk) => { body += chunk; });
131
+ res.on('end', () => {
132
+ try {
133
+ resolve(Boolean(JSON.parse(body).ready));
134
+ } catch {
135
+ resolve(false);
136
+ }
137
+ });
138
+ });
139
+ req.on('error', () => resolve(false));
140
+ req.on('timeout', () => { req.destroy(); resolve(false); });
141
+ });
142
+
143
+ if (ready) return { ready: true, timedOut: false };
144
+ await new Promise((r) => setTimeout(r, intervalMs));
145
+ }
146
+
147
+ return { ready: false, timedOut: true };
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-phone-local",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "Local/offline fork of NetworkChuck's claude-phone: talk to Claude Code over 3CX/SIP with faster-whisper STT + Piper TTS in one Docker container.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -178,7 +178,7 @@ function initializeServers() {
178
178
  }
179
179
 
180
180
  // HTTP server for TTS audio
181
- httpServer = createHttpServer(config.audio_dir, config.http_port);
181
+ httpServer = createHttpServer(config.audio_dir, config.http_port, function () { return isReady; });
182
182
  console.log("[" + new Date().toISOString() + "] HTTP Server started on port " + config.http_port);
183
183
 
184
184
  // WebSocket server for audio fork
@@ -24,9 +24,13 @@ const FILE_MAX_AGE = 600000;
24
24
  *
25
25
  * @param {string} audioDir - Directory to serve audio files from
26
26
  * @param {number} port - Port to listen on (default: 3000)
27
+ * @param {() => boolean} [getReadiness] - Returns whether drachtio + FreeSWITCH
28
+ * are both connected. /health reflects this instead of always reporting
29
+ * healthy, so `claude-phone start` can actually wait for the stack to be
30
+ * able to accept calls instead of guessing with a fixed sleep.
27
31
  * @returns {Object} { app, server, saveAudio, getAudioUrl, close, finalize }
28
32
  */
29
- function createHttpServer(audioDir, port = 3000) {
33
+ function createHttpServer(audioDir, port = 3000, getReadiness) {
30
34
  const app = express();
31
35
 
32
36
  // Parse JSON bodies
@@ -58,10 +62,14 @@ function createHttpServer(audioDir, port = 3000) {
58
62
  }
59
63
  }));
60
64
 
61
- // Health check endpoint
65
+ // Health check endpoint. `ready` reflects whether drachtio + FreeSWITCH are
66
+ // both connected - the process being up and able to accept calls are not
67
+ // the same thing, and a caller landing in the gap gets a SIP 503.
62
68
  app.get('/health', (req, res) => {
69
+ const ready = typeof getReadiness === 'function' ? getReadiness() : true;
63
70
  res.json({
64
71
  status: 'healthy',
72
+ ready,
65
73
  timestamp: new Date().toISOString(),
66
74
  audioDir,
67
75
  port