claude-code-hub 1.25.0 → 1.27.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.
package/server.js CHANGED
@@ -160,6 +160,7 @@ function resolveHubConfig() {
160
160
  if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
161
161
  if (Array.isArray(saved.apps)) config.apps = saved.apps;
162
162
  if (saved.keys !== undefined) config.keys = saved.keys;
163
+ if (saved.voice && typeof saved.voice === 'object') config.voice = saved.voice;
163
164
  copyLive(saved, config);
164
165
  const savedPrevious = typeof saved.previousConfigDir === 'string' ? canonical(saved.previousConfigDir) : null;
165
166
  if (dirs.includes(savedPrevious) && savedPrevious !== active) config.previousConfigDir = savedPrevious;
@@ -964,6 +965,7 @@ app.get('/api/config', (_req, res) => {
964
965
  activeConfigDir: hubConfig.activeConfigDir,
965
966
  defaultConfigDir,
966
967
  configDirSwitch: configDirSwitch(hubConfig),
968
+ voice: voice && { provider: voice.provider, auto: voice.auto, commands: voice.commands },
967
969
  version: HUB_VERSION,
968
970
  });
969
971
  });
@@ -1325,6 +1327,71 @@ app.get('/api/projects', async (_req, res) => {
1325
1327
  }
1326
1328
  });
1327
1329
 
1330
+ // Voice control, off unless config.json has voice.enabled; lib/voice loads only then.
1331
+ const VOICE_LOG = path.join(HUB_DIR, 'voice.jsonl');
1332
+ const voice = hubConfig.voice?.enabled === true ? loadVoice() : null;
1333
+
1334
+ function loadVoice() {
1335
+ try {
1336
+ const active = () => hubConfig.activeConfigDir;
1337
+ const ctx = () => ({
1338
+ apps: appsConfig(),
1339
+ actions: ACTIONS,
1340
+ configDir: active() && active() !== defaultConfigDir ? active() : null,
1341
+ });
1342
+ return require('./lib/voice').createVoice(hubConfig.voice, ctx);
1343
+ } catch (err) {
1344
+ console.log(`Voice is off: ${err.message}`);
1345
+ return null;
1346
+ }
1347
+ }
1348
+
1349
+ const heldOn = (req) => (typeof req.query.app === 'string' ? req.query.app : undefined);
1350
+
1351
+ // The browser provider: the page posts the recognized text so far while the key is held, and the
1352
+ // final text on release. It sits above /api/voice/:op, which would take "text" as an op.
1353
+ app.post('/api/voice/text', express.json({ limit: '64kb' }), async (req, res) => {
1354
+ if (!voice) return res.status(404).json({ error: 'voice is off' });
1355
+ if (typeof req.body?.text !== 'string') return res.status(400).json({ error: 'text is required' });
1356
+ const t0 = Date.now();
1357
+ const r = await voice.text(req.body.text, req.body.final === true, heldOn(req));
1358
+ if (req.body.final === true) logVoice(r, t0, { provider: 'browser' });
1359
+ res.json(r);
1360
+ });
1361
+
1362
+ app.post('/api/voice/:op', async (req, res) => {
1363
+ if (!voice) return res.status(404).json({ error: 'voice is off' });
1364
+ const { op } = req.params;
1365
+ if (!['start', 'stop', 'dictate', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
1366
+ const t0 = Date.now();
1367
+ const r = await voice[op](heldOn(req));
1368
+ if ((op === 'stop' || op === 'dictate') && r.ok) logVoice(r, t0);
1369
+ res.status(r.ok ? 200 : 502).json(r);
1370
+ });
1371
+
1372
+ function logVoice(r, t0, extra) {
1373
+ const entry = { at: new Date().toISOString(), text: r.text, command: r.command, ms: Date.now() - t0, ...extra };
1374
+ fs.appendFile(VOICE_LOG, `${JSON.stringify(entry)}\n`, () => {});
1375
+ }
1376
+
1377
+ // Live speech: the page sends mic audio while the key is held, about 5 posts a second, and each
1378
+ // answer carries the text so far. 501 sends the page back to /api/voice/start and /stop.
1379
+ app.post(
1380
+ '/api/voice/stream/:op',
1381
+ express.raw({ type: 'application/octet-stream', limit: '256kb' }),
1382
+ async (req, res) => {
1383
+ if (!voice) return res.status(404).json({ error: 'voice is off' });
1384
+ const { op } = req.params;
1385
+ if (!['start', 'audio', 'finish', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
1386
+ if (!voice.stream) return res.status(501).json({ error: 'no live speech' });
1387
+ if (op === 'audio' && !Buffer.isBuffer(req.body)) return res.status(400).json({ error: 'audio is required' });
1388
+ const t0 = Date.now();
1389
+ const r = await voice.stream[op](req.body, heldOn(req));
1390
+ if (op === 'finish' && r.ok) logVoice(r, t0, { live: true });
1391
+ res.status(r.ok ? 200 : r.unsupported ? 501 : 502).json(r);
1392
+ },
1393
+ );
1394
+
1328
1395
  // The tray stops the hub through this rather than taskkill, so the children and hub.json are cleaned up.
1329
1396
  app.post('/api/shutdown', (_req, res) => {
1330
1397
  console.log('Shutdown asked for over the API');