claude-phone-local 2.1.1 → 2.2.0

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.
@@ -159,10 +159,22 @@ SLACK DELIVERY: When the caller requests delivery to Slack (phrases like "send t
159
159
 
160
160
  The caller may hang up while you're working (they'll hear hold music). That's fine - complete the work and send to Slack. They'll see it there.
161
161
 
162
+ END OF CALL: The phone app matches a fixed list of goodbye words (bye, goodbye, hang up, etc.) to end the call automatically, so it misses anything phrased differently - "that's everything, thanks", "I'm all set", "nothing else for now", "we're done here". You understand intent better than a keyword match does, so ALSO add a third line whenever the caller's message signals the conversation is actually finished (a clear goodbye/thanks-and-done in any wording, in any language) - not for a mid-conversation pause, not for "give me a second", not merely because their immediate question was answered:
163
+
164
+ 🔚 END_CALL: true
165
+
166
+ Omit this line entirely (do not write "false") on every turn that isn't a real goodbye - most turns won't have it.
167
+
162
168
  Example query: "What's the weather in Royce City?"
163
169
  Example response:
164
170
  🗣️ VOICE_RESPONSE: It's 65 degrees and partly cloudy in Royce City right now. Great weather for being outside!
165
171
  🎯 COMPLETED: Weather lookup for Royce City done.
172
+
173
+ Example query: "Great, that's everything, thanks!"
174
+ Example response:
175
+ 🗣️ VOICE_RESPONSE: You're welcome! Have a great day.
176
+ 🎯 COMPLETED: Caller signed off, no further requests.
177
+ 🔚 END_CALL: true
166
178
  [END VOICE CONTEXT]
167
179
 
168
180
  `;
@@ -6,7 +6,7 @@ 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
8
  import { isClaudeInstalled } from '../utils.js';
9
- import { checkClaudeApiServer, waitForVoiceAppReady } from '../network.js';
9
+ import { checkClaudeApiServer, waitForVoiceAppReady, waitForClaudeApiServerReady } from '../network.js';
10
10
  import { runPrereqChecks } from '../prereqs.js';
11
11
 
12
12
  /**
@@ -91,7 +91,17 @@ async function startApiServer(config) {
91
91
  spinner.warn('Claude API server already running');
92
92
  } else {
93
93
  await startServer(config.paths.claudeApiServer, config.server.claudeApiPort);
94
- spinner.succeed(`Claude API server started on port ${config.server.claudeApiPort}`);
94
+ // startServer only confirms the process was spawned, not that it
95
+ // actually bound its port and stayed up (e.g. a stale process already
96
+ // on that port crashes it with EADDRINUSE within milliseconds).
97
+ const readiness = await waitForClaudeApiServerReady(`http://localhost:${config.server.claudeApiPort}`);
98
+ if (readiness.healthy) {
99
+ spinner.succeed(`Claude API server started on port ${config.server.claudeApiPort}`);
100
+ } else {
101
+ spinner.fail(`Claude API server did not become healthy: ${readiness.error || 'timed out'}`);
102
+ console.log(chalk.yellow(`\n Check the log: claude-phone logs api-server\n`));
103
+ process.exit(1);
104
+ }
95
105
  }
96
106
  } catch (error) {
97
107
  spinner.fail(`Failed to start server: ${error.message}`);
@@ -332,7 +342,17 @@ async function startBoth(config, isPiMode) {
332
342
  spinner.warn('Claude API server already running');
333
343
  } else {
334
344
  await startServer(config.paths.claudeApiServer, config.server.claudeApiPort);
335
- spinner.succeed(`Claude API server started on port ${config.server.claudeApiPort}`);
345
+ // startServer only confirms the process was spawned, not that it
346
+ // actually bound its port and stayed up (e.g. a stale process
347
+ // already on that port crashes it with EADDRINUSE within ms).
348
+ const apiReadiness = await waitForClaudeApiServerReady(`http://localhost:${config.server.claudeApiPort}`);
349
+ if (apiReadiness.healthy) {
350
+ spinner.succeed(`Claude API server started on port ${config.server.claudeApiPort}`);
351
+ } else {
352
+ spinner.fail(`Claude API server did not become healthy: ${apiReadiness.error || 'timed out'}`);
353
+ console.log(chalk.yellow(`\n Check the log: claude-phone logs api-server\n`));
354
+ process.exit(1);
355
+ }
336
356
  }
337
357
  } catch (error) {
338
358
  spinner.fail(`Failed to start server: ${error.message}`);
@@ -108,6 +108,33 @@ export async function checkClaudeApiServer(url) {
108
108
  });
109
109
  }
110
110
 
111
+ /**
112
+ * Poll claude-api-server's /health until it responds, or the timeout
113
+ * elapses. startServer() only confirms the process was spawned (got a PID) -
114
+ * not that it actually bound its port and stayed up. A crash right after
115
+ * spawn (e.g. EADDRINUSE from a stale process already on that port) was
116
+ * previously reported as "✔ Claude API server started" and "✓ All services
117
+ * running!" with no indication anything was wrong.
118
+ * @param {string} url - claude-api-server base URL (e.g. http://localhost:3333)
119
+ * @param {object} [opts]
120
+ * @param {number} [opts.timeoutMs=10000] - Give up after this long
121
+ * @param {number} [opts.intervalMs=500] - Poll interval
122
+ * @returns {Promise<{healthy: boolean, timedOut: boolean, error?: string}>}
123
+ */
124
+ export async function waitForClaudeApiServerReady(url, { timeoutMs = 10000, intervalMs = 500 } = {}) {
125
+ const deadline = Date.now() + timeoutMs;
126
+ let lastError;
127
+
128
+ while (Date.now() < deadline) {
129
+ const result = await checkClaudeApiServer(url);
130
+ if (result.reachable && result.healthy) return { healthy: true, timedOut: false };
131
+ lastError = result.error;
132
+ await new Promise((r) => setTimeout(r, intervalMs));
133
+ }
134
+
135
+ return { healthy: false, timedOut: true, error: lastError };
136
+ }
137
+
111
138
  /**
112
139
  * Poll voice-app's /health until it reports drachtio + FreeSWITCH both
113
140
  * connected, or the timeout elapses. Fixes `claude-phone start` reporting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-phone-local",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
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": {
@@ -162,6 +162,17 @@ function extractVoiceLine(response) {
162
162
  return response.substring(0, 500).trim();
163
163
  }
164
164
 
165
+ /**
166
+ * Whether Claude's raw response signals the conversation is actually over.
167
+ * Complements isGoodbye()'s fixed phrase match on the caller's own words -
168
+ * Claude sees the full exchange and can recognize a sign-off phrased in ways
169
+ * the keyword list doesn't cover ("that's everything, thanks", "I'm all
170
+ * set"), in any language, without maintaining a growing keyword list.
171
+ */
172
+ function hasEndCallMarker(response) {
173
+ return /🔚\s*END_CALL:\s*true/i.test(response);
174
+ }
175
+
165
176
  /**
166
177
  * Play a clip the caller is allowed to interrupt.
167
178
  *
@@ -443,6 +454,13 @@ async function conversationLoop(endpoint, dialog, callUuid, options, deviceConfi
443
454
  const responseBarge = await playInterruptible(endpoint, session, responseUrl);
444
455
  if (responseBarge) {
445
456
  pendingUtterance = responseBarge;
457
+ } else if (hasEndCallMarker(claudeResponse)) {
458
+ // Claude judged this a real sign-off from the caller's own words
459
+ // (not just isGoodbye()'s fixed phrase match) - she already said
460
+ // her goodbye as part of the normal response above, so just end
461
+ // the call rather than waiting for another turn.
462
+ console.log('[' + new Date().toISOString() + '] CONVERSATION Claude signaled end of call');
463
+ break;
446
464
  }
447
465
  }
448
466