handmux 0.26.0 → 0.27.2

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 (48) hide show
  1. package/README.md +20 -93
  2. package/README.zh-CN.md +20 -93
  3. package/dist/package.json +19 -19
  4. package/dist/public/assets/index-B4pKf3IW.js +356 -0
  5. package/dist/public/assets/index-Cdrh2CUp.css +32 -0
  6. package/dist/public/assets/pcm-worklet-NzlBQ6-t.js +1 -0
  7. package/dist/public/index.html +2 -2
  8. package/dist/src/agent-runtime/adapter.js +2 -0
  9. package/dist/src/agent-runtime/builtinRuntime.js +3 -1
  10. package/dist/src/agent-runtime/conversationActivation.js +136 -4
  11. package/dist/src/agent-runtime/run.js +23 -2
  12. package/dist/src/agent-runtime/runtime.js +174 -15
  13. package/dist/src/agent-runtime/tmuxRuntime.js +3 -7
  14. package/dist/src/agents/codex.js +78 -10
  15. package/dist/src/agents/codexActivationReceipt.js +149 -0
  16. package/dist/src/agents/codexConversation.js +59 -20
  17. package/dist/src/agents/codexConversationActivation.js +265 -65
  18. package/dist/src/agents/codexOpenSession.js +164 -0
  19. package/dist/src/agents/nativeInbox.js +24 -56
  20. package/dist/src/agents/scanUtils.js +7 -1
  21. package/dist/src/apiErrors.js +10 -1
  22. package/dist/src/asr/config.js +29 -0
  23. package/dist/src/asr/providerRegistry.js +19 -0
  24. package/dist/src/asr/providers/tencent.js +135 -0
  25. package/dist/src/asr/providers/xfyun.js +73 -0
  26. package/dist/src/asr/tencentSentence.js +111 -0
  27. package/dist/src/asr/tencentSign.js +20 -0
  28. package/dist/src/asr/verify.js +80 -0
  29. package/dist/src/cli/codexManaged.js +13 -2
  30. package/dist/src/cli/i18n/en.js +16 -1
  31. package/dist/src/cli/i18n/zh.js +16 -1
  32. package/dist/src/cli/options.js +29 -2
  33. package/dist/src/cli/setupModel.js +49 -5
  34. package/dist/src/cli/setupWizard.js +149 -29
  35. package/dist/src/cli/supervisor.js +24 -8
  36. package/dist/src/cli/supervisorLaunch.js +26 -0
  37. package/dist/src/codexAppServer.js +41 -4
  38. package/dist/src/routes/agents.js +51 -14
  39. package/dist/src/routes/system.js +109 -10
  40. package/dist/src/server.js +11 -2
  41. package/dist/src/terminalStream.js +8 -30
  42. package/dist/src/tmux/commands.js +14 -0
  43. package/dist/src/tmux/controlProtocol.js +26 -0
  44. package/dist/src/tmux/paneOutputCapture.js +180 -0
  45. package/package.json +5 -5
  46. package/dist/public/assets/index-BVFKs5dN.css +0 -32
  47. package/dist/public/assets/index-KDa5PCoi.js +0 -356
  48. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +0 -1
@@ -3,6 +3,15 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { spawn as spawnChild } from 'node:child_process';
5
5
  const PANE_RE = /^%\d+$/;
6
+ function hasExplicitCwd(args) {
7
+ const optionsEnd = args.indexOf('--');
8
+ const options = optionsEnd === -1 ? args : args.slice(0, optionsEnd);
9
+ return options.some((arg) => arg === '-C' || arg === '--cd'
10
+ || arg.startsWith('--cd=') || (arg.startsWith('-C') && arg.length > 2));
11
+ }
12
+ export function managedCodexArgs(args, cwd) {
13
+ return hasExplicitCwd(args) ? [...args] : ['--cd', cwd, ...args];
14
+ }
6
15
  const errorCode = (error) => {
7
16
  if (typeof error !== 'object' || error === null || !('code' in error))
8
17
  return null;
@@ -41,7 +50,7 @@ function finish(child) {
41
50
  }
42
51
  // One App Server belongs to one tmux pane. The TUI and Handmux connect to that pane-owned Unix socket,
43
52
  // so thread identity, messages, approvals, settings, interrupts and inbox state share one runtime.
44
- export async function runManagedCodexProcess(args = [], { home = os.homedir(), env = process.env, spawn = spawnChild, mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }), unlink = (file) => { try {
53
+ export async function runManagedCodexProcess(args = [], { home = os.homedir(), cwd = process.cwd(), env = process.env, spawn = spawnChild, mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }), unlink = (file) => { try {
45
54
  fs.unlinkSync(file);
46
55
  }
47
56
  catch (error) {
@@ -60,7 +69,9 @@ catch (error) {
60
69
  let tui = null;
61
70
  try {
62
71
  await waitForSocket(socketPath, appServer, waitOptions);
63
- tui = spawn('codex', ['--remote', `unix://${socketPath}`, ...args], {
72
+ tui = spawn('codex', [
73
+ '--remote', `unix://${socketPath}`, ...managedCodexArgs(args, cwd),
74
+ ], {
64
75
  env: managedEnv,
65
76
  stdio: 'inherit',
66
77
  });
@@ -273,11 +273,26 @@ export default {
273
273
  'setup.pushOff': 'Turn off push',
274
274
  'setup.voiceOff': 'Turn off voice',
275
275
  'setup.voiceKeep': 'keep voice input configured?',
276
- 'setup.voiceSetup': 'Turn on voice input? (talk to your phone → text; needs an iFlytek/xfyun account)',
276
+ 'setup.voiceSetup': 'Turn on voice input? (live dictation or whole-sentence recognition)',
277
+ 'setup.voiceProvider': 'Voice provider',
278
+ 'setup.voiceTencent': 'Tencent Cloud real-time ASR',
279
+ 'setup.voiceTencentHint': 'WebSocket 2.0 · live partials',
280
+ 'setup.voiceTencentSentence': 'Tencent Cloud sentence ASR',
281
+ 'setup.voiceTencentSentenceHint': 'Tap to record · returns after stopping',
282
+ 'setup.voiceXfyun': 'iFlytek voice dictation',
283
+ 'setup.voiceXfyunHint': 'live partials',
277
284
  'setup.voiceAppId': 'xfyun appId',
278
285
  'setup.voiceApiKey': 'xfyun apiKey',
279
286
  'setup.voiceApiSecret': 'xfyun apiSecret',
287
+ 'setup.voiceTencentAppId': 'Tencent Cloud appId',
288
+ 'setup.voiceTencentSecretId': 'Tencent Cloud secretId',
289
+ 'setup.voiceTencentSecretKey': 'Tencent Cloud secretKey',
290
+ 'setup.voiceTencentEngine': 'engineModelType (16k PCM; default: 16k_zh)',
280
291
  'setup.voiceSkipped': ' (skipped — missing fields)',
292
+ 'setup.voiceVerifying': '→ Verifying {profile} with the provider…',
293
+ 'setup.voiceVerified': '✓ Voice configuration verified',
294
+ 'setup.voiceVerifyFailed': '✗ Voice verification failed: {msg}. Check the values or network, then edit a field to retry.',
295
+ 'setup.voiceVerifyUnavailable': '⚠ This voice provider does not support automatic verification yet.',
281
296
  'setup.cfLogin': '→ logging in to Cloudflare (a browser will open) …',
282
297
  'setup.cfReuse': '✓ reusing existing tunnel {name} ({id})',
283
298
  'setup.cfCredMissing1': '⚠ credentials file {file} not found on this machine — the tunnel was likely',
@@ -272,11 +272,26 @@ export default {
272
272
  'setup.pushOff': '关闭推送',
273
273
  'setup.voiceOff': '关闭语音',
274
274
  'setup.voiceKeep': '保留已配置的语音输入吗?',
275
- 'setup.voiceSetup': '开启语音输入吗?(对着手机说话转文字;需讯飞账号)',
275
+ 'setup.voiceSetup': '开启语音输入吗?(支持实时听写或整句识别)',
276
+ 'setup.voiceProvider': '语音服务',
277
+ 'setup.voiceTencent': '腾讯云实时语音识别',
278
+ 'setup.voiceTencentHint': 'WebSocket 2.0 · 实时返回',
279
+ 'setup.voiceTencentSentence': '腾讯云一句话识别',
280
+ 'setup.voiceTencentSentenceHint': '点击录音 · 停止后整句返回',
281
+ 'setup.voiceXfyun': '讯飞语音听写',
282
+ 'setup.voiceXfyunHint': '实时返回',
276
283
  'setup.voiceAppId': 'xfyun appId',
277
284
  'setup.voiceApiKey': 'xfyun apiKey',
278
285
  'setup.voiceApiSecret': 'xfyun apiSecret',
286
+ 'setup.voiceTencentAppId': 'Tencent Cloud appId',
287
+ 'setup.voiceTencentSecretId': 'Tencent Cloud secretId',
288
+ 'setup.voiceTencentSecretKey': 'Tencent Cloud secretKey',
289
+ 'setup.voiceTencentEngine': 'engineModelType(16k PCM,默认 16k_zh)',
279
290
  'setup.voiceSkipped': ' (已跳过 —— 字段缺失)',
291
+ 'setup.voiceVerifying': '→ 正在向服务商验证 {profile}…',
292
+ 'setup.voiceVerified': '✓ 语音配置验证通过',
293
+ 'setup.voiceVerifyFailed': '✗ 语音配置验证失败:{msg}。请检查填写内容或网络,修改任一字段后会重新验证。',
294
+ 'setup.voiceVerifyUnavailable': '⚠ 当前语音服务商暂不支持自动验证。',
280
295
  'setup.cfLogin': '→ 正在登录 Cloudflare(会打开一个浏览器)…',
281
296
  'setup.cfReuse': '✓ 复用已有隧道 {name}({id})',
282
297
  'setup.cfCredMissing1': '⚠ 本机找不到凭据文件 {file} —— 该隧道很可能是',
@@ -2,6 +2,7 @@
2
2
  // Resolution order is flags > config file > env > built-in default. The token is ALWAYS materialised
3
3
  // (generated when unset) so a public tunnel can never come up token-less.
4
4
  import crypto from 'node:crypto';
5
+ import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
5
6
  import { normalizeShortcuts } from '../shortcutConfig.js';
6
7
  export const TUNNELS = ['none', 'cloudflare', 'cloudflare-named', 'ssh', 'natapp', 'cpolar'];
7
8
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -90,7 +91,8 @@ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen =
90
91
  uploadExts: optionalString(pick('uploadExts', env.HANDMUX_UPLOAD_EXTS), 'upload-exts'),
91
92
  previewDomain: optionalString(pick('previewDomain', env.HANDMUX_PREVIEW_DOMAIN), 'preview-domain'),
92
93
  vapid: parseVapid(fileCfg.vapid), // { public, private, subject } — push notifications
93
- xfyun: parseXfyun(fileCfg.xfyun), // { appId, apiKey, apiSecret } — voice input
94
+ voice: parseVoice(fileCfg.voice, fileCfg.xfyun),
95
+ xfyun: parseXfyun(fileCfg.xfyun), // legacy voice input; parseVoice migrates it at runtime
94
96
  shortcuts: normalizeShortcuts(fileCfg.shortcuts),
95
97
  // An explicit public URL is honoured for ANY tunnel mode — including 'none', so someone who runs their
96
98
  // own tunnel/reverse-proxy can still have handmux advertise (print + QR) their real domain. The
@@ -162,6 +164,25 @@ function parseVapid(value) {
162
164
  function parseXfyun(value) {
163
165
  return stringFields(value, ['appId', 'apiKey', 'apiSecret']);
164
166
  }
167
+ function parseVoice(value, legacyXfyun) {
168
+ if (isRecord(value)) {
169
+ const adapter = voiceProviderRegistry.get(value.provider);
170
+ const providers = isRecord(value.providers) ? value.providers : {};
171
+ if (adapter) {
172
+ if (value.enabled === false)
173
+ return null;
174
+ const parsed = {};
175
+ for (const candidate of voiceProviderRegistry.adapters) {
176
+ const config = stringFields(providers[candidate.id], candidate.fields.map((field) => field.key));
177
+ if (config)
178
+ parsed[candidate.id] = config;
179
+ }
180
+ return { provider: adapter.id, mode: providerMode(adapter, value.mode), providers: parsed };
181
+ }
182
+ }
183
+ const xfyun = parseXfyun(legacyXfyun);
184
+ return xfyun ? { provider: 'xfyun', mode: 'streaming', providers: { xfyun } } : null;
185
+ }
165
186
  function resolvePublicUrl(flags, fileCfg, env, tunnel) {
166
187
  if (flags.publicUrl != null)
167
188
  return flags.publicUrl;
@@ -240,6 +261,12 @@ export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = pr
240
261
  }
241
262
  // Integrations live only in the file (secrets); show presence, never the keys.
242
263
  rows.push({ key: 'push (vapid)', origin: fileCfg.vapid ? (cfgPath || 'file') : 'default', display: fileCfg.vapid ? 'on' : 'off' });
243
- rows.push({ key: 'voice (xfyun)', origin: fileCfg.xfyun ? (cfgPath || 'file') : 'default', display: fileCfg.xfyun ? 'on' : 'off' });
264
+ const voice = parseVoice(fileCfg.voice, fileCfg.xfyun);
265
+ const voiceExplicitlyDisabled = isRecord(fileCfg.voice) && fileCfg.voice.enabled === false;
266
+ rows.push({
267
+ key: 'voice',
268
+ origin: voice || voiceExplicitlyDisabled ? (cfgPath || 'file') : 'default',
269
+ display: voice ? voice.provider : 'off',
270
+ });
244
271
  return rows;
245
272
  }
@@ -3,6 +3,7 @@
3
3
  // interactive shell (setupWizard.ts) so the tested, side-effect-free logic stands on its own. Depends only
4
4
  // on i18n (t/getLocale) for user-facing strings — otherwise deterministic given its inputs.
5
5
  import { t, getLocale } from './i18n/index.js';
6
+ import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
6
7
  import { TUNNELS } from './options.js';
7
8
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
8
9
  const isTunnel = (value) => typeof value === 'string' && TUNNELS.includes(value);
@@ -16,6 +17,25 @@ const stringObject = (value, keys) => {
16
17
  result[key] = value[key];
17
18
  return result;
18
19
  };
20
+ // Voice credentials and identifiers are copied from provider consoles, where an unnoticed leading or
21
+ // trailing space makes an otherwise valid signature fail. Normalize at the persistence boundary as well as
22
+ // in the interactive editor so every setup caller gets the same behavior.
23
+ export function normalizeVoiceConfig(voice) {
24
+ const providers = {};
25
+ for (const [provider, value] of Object.entries(voice.providers)) {
26
+ if (!value) {
27
+ providers[provider] = value;
28
+ continue;
29
+ }
30
+ const normalized = {};
31
+ for (const [key, fieldValue] of Object.entries(value)) {
32
+ if (typeof fieldValue === 'string')
33
+ normalized[key] = fieldValue.trim();
34
+ }
35
+ providers[provider] = normalized;
36
+ }
37
+ return { ...voice, providers };
38
+ }
19
39
  // ~/.cloudflared/config.yml for a named tunnel: route the hostname to the local handmux port.
20
40
  export function cfConfigYaml({ tunnelName, credentialsFile, hostname, port, }) {
21
41
  return [
@@ -61,7 +81,7 @@ const WIZARD_KEYS = [
61
81
  'lang', 'name', 'port', 'tunnel', 'token', 'previewDomain',
62
82
  'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
63
83
  'authtoken', 'cpolarRegion',
64
- 'vapid', 'xfyun',
84
+ 'vapid', 'voice', 'xfyun',
65
85
  ];
66
86
  // The tunnel-specific keys, cleared whenever the tunnel changes so a switch never carries a stale field.
67
87
  export const TUNNEL_KEYS = ['sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl', 'authtoken', 'cpolarRegion'];
@@ -98,8 +118,8 @@ export function configFromAnswers(a) {
98
118
  }
99
119
  if (a.vapid)
100
120
  cfg.vapid = a.vapid;
101
- if (a.xfyun)
102
- cfg.xfyun = a.xfyun;
121
+ if (a.voice)
122
+ cfg.voice = normalizeVoiceConfig(a.voice);
103
123
  return cfg;
104
124
  }
105
125
  // Fold this run's answers into an existing config: preserve every non-wizard field, replace the wizard's
@@ -132,13 +152,37 @@ export function answersFromConfig(config = {}) {
132
152
  if (typeof cfg.remotePort === 'number' && Number.isFinite(cfg.remotePort))
133
153
  a.remotePort = cfg.remotePort;
134
154
  const vapid = stringObject(cfg.vapid, ['public', 'private', 'subject']);
155
+ const voice = voiceFromConfig(cfg.voice);
135
156
  const xfyun = stringObject(cfg.xfyun, ['appId', 'apiKey', 'apiSecret']);
136
157
  if (vapid)
137
158
  a.vapid = vapid;
138
- if (xfyun)
139
- a.xfyun = xfyun;
159
+ if (voice)
160
+ a.voice = voice;
161
+ else if (xfyun)
162
+ a.voice = normalizeVoiceConfig({
163
+ provider: 'xfyun', mode: 'streaming', providers: { xfyun },
164
+ });
140
165
  return a;
141
166
  }
167
+ function voiceFromConfig(value) {
168
+ if (!isRecord(value) || !isRecord(value.providers))
169
+ return undefined;
170
+ const adapter = voiceProviderRegistry.get(value.provider);
171
+ if (!adapter)
172
+ return undefined;
173
+ const providers = {};
174
+ for (const candidate of voiceProviderRegistry.adapters) {
175
+ const config = stringObject(value.providers[candidate.id], candidate.fields.map((field) => field.key));
176
+ if (config)
177
+ providers[candidate.id] = config;
178
+ }
179
+ return normalizeVoiceConfig({
180
+ ...(value.enabled === false ? { enabled: false } : {}),
181
+ provider: adapter.id,
182
+ mode: providerMode(adapter, value.mode),
183
+ providers,
184
+ });
185
+ }
142
186
  // The dim one-line summary shown next to the Connection row (and reused nowhere else). Pure.
143
187
  export function summarizeConnection(a) {
144
188
  const bare = (url) => String(url || '').replace(/^https?:\/\//, '');
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import webpush from 'web-push';
11
+ import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
11
12
  import { configPath } from './state.js';
12
13
  import { defaultGen as genToken, TUNNELS } from './options.js';
13
14
  import { resolveCloudflared } from './cloudflared.js';
@@ -28,8 +29,8 @@ const isTunnel = (value) => (typeof value === 'string' && TUNNELS.includes(value
28
29
  // The pure answer↔config model + validators + cloudflared text helpers live in setupModel.js. Imported for
29
30
  // the interactive shell below; re-exported so their historical import path (this module) stays valid for
30
31
  // callers and tests.
31
- import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
32
- export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
32
+ import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
33
+ export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
33
34
  function readExisting(file) {
34
35
  try {
35
36
  return new PrivateStateStore(file).readStrict() || {};
@@ -83,7 +84,12 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
83
84
  { value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
84
85
  { value: 'browser', label: t('setup.secBrowser'), hint: a.previewDomain || t('setup.browserOff') },
85
86
  { value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
86
- { value: 'voice', label: t('setup.secVoice'), hint: a.xfyun ? (a.xfyun.appId || t('setup.on')) : t('setup.off') },
87
+ {
88
+ value: 'voice', label: t('setup.secVoice'),
89
+ hint: a.voice && a.voice.enabled !== false
90
+ ? `${voiceProfileLabel(a.voice)} · ${a.voice.providers[a.voice.provider]?.appId || t('setup.on')}`
91
+ : t('setup.off'),
92
+ },
87
93
  // A CLI-tool preference (language of handmux's own terminal output), not an app setting — so it
88
94
  // sits at the bottom of the settings, just above the actions.
89
95
  { value: 'language', label: t('setup.secLanguage'), hint: a.lang === 'zh' ? '中文' : 'English' },
@@ -128,11 +134,11 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
128
134
  delete a.vapid;
129
135
  }
130
136
  else if (choice === 'voice') {
131
- const xfyun = await editVoice(a);
132
- if (xfyun)
133
- a.xfyun = xfyun;
137
+ const voice = await editVoice(a);
138
+ if (voice)
139
+ a.voice = voice;
134
140
  else
135
- delete a.xfyun;
141
+ delete a.voice;
136
142
  }
137
143
  }
138
144
  catch (e) {
@@ -507,55 +513,84 @@ async function editPush(a) {
507
513
  } // Esc in a sub-edit → back to the mini-hub
508
514
  }
509
515
  }
510
- // Voice input (iFlytek/xfyun) — three credentials; appId is shown/edited in the clear, the two secrets show
511
- // only a masked preview and are replaced (never revealed) when edited.
516
+ // Voice input provider hub. Credentials for multiple providers may coexist, but only the explicitly selected
517
+ // provider is injected into the server process. Secrets are masked and replaced, never revealed.
512
518
  async function editVoice(a) {
513
- let x = a.xfyun;
514
- if (!x) {
519
+ let voice = a.voice;
520
+ if (!voice || voice.enabled === false) {
515
521
  let on;
516
522
  try {
517
523
  on = await ask(confirm({ message: withBack(t('setup.voiceSetup')), initialValue: false, ...yesno() }));
518
524
  }
519
525
  catch (e) {
520
526
  if (e === CANCELLED)
521
- return undefined;
527
+ return voice;
522
528
  throw e;
523
529
  }
524
530
  if (!on)
525
- return undefined;
526
- const appId = await ask(text({ message: t('setup.voiceAppId'), validate: validateNonEmpty('appId') }));
527
- const apiKey = await ask(password({ message: t('setup.voiceApiKey'), validate: validateNonEmpty('apiKey') }));
528
- const apiSecret = await ask(password({ message: t('setup.voiceApiSecret'), validate: validateNonEmpty('apiSecret') }));
529
- x = { appId, apiKey, apiSecret };
531
+ return voice;
532
+ if (voice) {
533
+ voice = await ensureVoiceProvider({ ...voice, enabled: true });
534
+ }
535
+ else {
536
+ const profile = await chooseVoiceProfile({ provider: 'tencent', mode: 'streaming' });
537
+ voice = await ensureVoiceProvider({ ...profile, enabled: true, providers: {} });
538
+ }
539
+ await verifyVoiceConfiguration(voice);
530
540
  }
531
541
  for (;;) {
542
+ const provider = voice.provider;
543
+ const adapter = voiceProviderRegistry.get(provider);
544
+ if (!adapter)
545
+ throw new Error(`unknown voice provider: ${provider}`);
546
+ const current = voice.providers[provider] || {};
532
547
  let pick;
533
548
  try {
534
549
  pick = await ask(select({
535
550
  message: withBack(t('setup.secVoice')),
536
551
  options: [
537
- { value: 'appId', label: 'appId', hint: x.appId || '' },
538
- { value: 'apiKey', label: 'apiKey', hint: maskSecret(x.apiKey) },
539
- { value: 'apiSecret', label: 'apiSecret', hint: maskSecret(x.apiSecret) },
552
+ { value: 'provider', label: t('setup.voiceProvider'), hint: voiceProfileLabel(voice) },
553
+ ...adapter.fields.map((field) => ({
554
+ value: field.key,
555
+ label: field.key,
556
+ hint: field.secret ? maskSecret(current[field.key])
557
+ : current[field.key] || field.defaultValue || '',
558
+ })),
540
559
  { value: 'off', label: t('setup.voiceOff') },
541
560
  ],
542
- initialValue: 'appId',
561
+ initialValue: 'provider',
543
562
  }));
544
563
  }
545
564
  catch (e) {
546
565
  if (e === CANCELLED)
547
- return x;
566
+ return voice;
548
567
  throw e;
549
568
  }
550
569
  if (pick === 'off')
551
- return undefined;
570
+ return { ...voice, enabled: false };
552
571
  try {
553
- if (pick === 'appId')
554
- x = { ...x, appId: await ask(text({ message: t('setup.voiceAppId'), initialValue: x.appId || '', validate: validateNonEmpty('appId') })) };
555
- else if (pick === 'apiKey')
556
- x = { ...x, apiKey: await ask(password({ message: t('setup.voiceApiKey'), validate: validateNonEmpty('apiKey') })) };
557
- else if (pick === 'apiSecret')
558
- x = { ...x, apiSecret: await ask(password({ message: t('setup.voiceApiSecret'), validate: validateNonEmpty('apiSecret') })) };
572
+ if (pick === 'provider') {
573
+ const selected = await chooseVoiceProfile(voice);
574
+ voice = await ensureVoiceProvider({ ...voice, ...selected });
575
+ await verifyVoiceConfiguration(voice);
576
+ }
577
+ else {
578
+ const field = adapter.fields.find((candidate) => candidate.key === pick);
579
+ if (!field)
580
+ continue;
581
+ const config = { ...current };
582
+ config[field.key] = String(field.secret
583
+ ? await ask(password({ message: t(field.promptKey), validate: validateNonEmpty(field.key) }))
584
+ : await ask(text({
585
+ message: t(field.promptKey),
586
+ initialValue: config[field.key] || field.defaultValue || '',
587
+ validate: validateNonEmpty(field.key),
588
+ }))).trim();
589
+ voice = normalizeVoiceConfig({
590
+ ...voice, providers: { ...voice.providers, [provider]: config },
591
+ });
592
+ await verifyVoiceConfiguration(voice);
593
+ }
559
594
  }
560
595
  catch (e) {
561
596
  if (e !== CANCELLED)
@@ -563,6 +598,91 @@ async function editVoice(a) {
563
598
  }
564
599
  }
565
600
  }
601
+ function voiceProfileLabel(voice) {
602
+ const adapter = voiceProviderRegistry.get(voice.provider);
603
+ const profile = adapter?.profiles.find((candidate) => candidate.mode === voice.mode)
604
+ ?? adapter?.profiles.find((candidate) => candidate.mode === adapter.defaultMode);
605
+ return profile ? t(profile.labelKey) : voice.provider;
606
+ }
607
+ async function chooseVoiceProfile(initial) {
608
+ const profiles = voiceProviderRegistry.adapters.flatMap((adapter) => (adapter.profiles.map((profile) => ({ adapter, profile, value: `${adapter.id}:${profile.mode}` }))));
609
+ const initialAdapter = voiceProviderRegistry.get(initial.provider) ?? voiceProviderRegistry.adapters[0];
610
+ if (!initialAdapter)
611
+ throw new Error('no voice providers registered');
612
+ const initialValue = `${initialAdapter.id}:${providerMode(initialAdapter, initial.mode)}`;
613
+ const selected = await ask(select({
614
+ message: t('setup.voiceProvider'),
615
+ options: profiles.map(({ profile, value }) => ({
616
+ value, label: t(profile.labelKey), hint: t(profile.hintKey),
617
+ })),
618
+ initialValue,
619
+ }));
620
+ const match = profiles.find((candidate) => candidate.value === selected);
621
+ return match ? { provider: match.adapter.id, mode: match.profile.mode }
622
+ : { provider: initialAdapter.id, mode: providerMode(initialAdapter, initial.mode) };
623
+ }
624
+ async function ensureVoiceProvider(voice) {
625
+ const adapter = voiceProviderRegistry.get(voice.provider);
626
+ if (!adapter)
627
+ throw new Error(`unknown voice provider: ${voice.provider}`);
628
+ const config = { ...voice.providers[adapter.id] };
629
+ for (const field of adapter.fields) {
630
+ const existing = config[field.key]?.trim();
631
+ if (existing) {
632
+ config[field.key] = existing;
633
+ continue;
634
+ }
635
+ if (field.defaultValue) {
636
+ config[field.key] = field.defaultValue;
637
+ continue;
638
+ }
639
+ config[field.key] = String(field.secret
640
+ ? await ask(password({ message: t(field.promptKey), validate: validateNonEmpty(field.key) }))
641
+ : await ask(text({
642
+ message: t(field.promptKey), initialValue: field.defaultValue || '',
643
+ validate: validateNonEmpty(field.key),
644
+ }))).trim();
645
+ }
646
+ return normalizeVoiceConfig({
647
+ ...voice, providers: { ...voice.providers, [adapter.id]: config },
648
+ });
649
+ }
650
+ async function verifyVoiceConfiguration(voice) {
651
+ const adapter = voiceProviderRegistry.get(voice.provider);
652
+ if (!adapter)
653
+ throw new Error(`unknown voice provider: ${voice.provider}`);
654
+ if (!adapter.verify) {
655
+ note(t('setup.voiceVerifyUnavailable'));
656
+ return false;
657
+ }
658
+ const providerConfig = voice.providers[adapter.id] || {};
659
+ const env = {};
660
+ for (const field of adapter.fields)
661
+ env[field.env] = providerConfig[field.key] || '';
662
+ const config = adapter.readConfig(env, providerMode(adapter, voice.mode));
663
+ if (!adapter.isConfigured(config)) {
664
+ note(t('setup.voiceVerifyFailed', { msg: t('setup.voiceSkipped') }));
665
+ return false;
666
+ }
667
+ note(t('setup.voiceVerifying', { profile: voiceProfileLabel(voice) }));
668
+ try {
669
+ await adapter.verify(config);
670
+ note(t('setup.voiceVerified'));
671
+ return true;
672
+ }
673
+ catch (error) {
674
+ let message = errorMessage(error);
675
+ for (const field of adapter.fields) {
676
+ if (!field.secret)
677
+ continue;
678
+ const secret = providerConfig[field.key];
679
+ if (secret)
680
+ message = message.split(secret).join('••••');
681
+ }
682
+ note(t('setup.voiceVerifyFailed', { msg: message }));
683
+ return false;
684
+ }
685
+ }
566
686
  // login (browser) → create → route dns → write config.yml. The only human step is the browser login.
567
687
  async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log, }) {
568
688
  const bin = await resolveCloudflared(home);
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  import os from 'node:os';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { createRequire } from 'node:module';
10
+ import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
10
11
  import { getDriver } from './drivers.js';
11
12
  import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
12
13
  import { initialSupervisorComponentState, reduceSupervisorComponent, } from './supervisorState.js';
@@ -114,7 +115,7 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
114
115
  const startServer = () => {
115
116
  // The server reads only process.env (no .env files) — the CLI resolved the one config file and we
116
117
  // hand the server everything it needs here. This is the single injection point: config.json fields →
117
- // the env names the server already reads (HANDMUX_* / VAPID_* / XFYUN_*).
118
+ // the env names the server reads. Voice injects only the selected provider's credentials.
118
119
  const env = {
119
120
  ...processRef.env,
120
121
  NODE_ENV: 'handmux',
@@ -142,13 +143,28 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
142
143
  if (cfg.vapid.subject)
143
144
  env.VAPID_SUBJECT = cfg.vapid.subject;
144
145
  }
145
- if (cfg.xfyun) {
146
- if (cfg.xfyun.appId)
147
- env.XFYUN_APPID = cfg.xfyun.appId;
148
- if (cfg.xfyun.apiKey)
149
- env.XFYUN_APIKEY = cfg.xfyun.apiKey;
150
- if (cfg.xfyun.apiSecret)
151
- env.XFYUN_APISECRET = cfg.xfyun.apiSecret;
146
+ // Config is authoritative for voice. Always remove inherited ASR state first, including when setup
147
+ // explicitly disabled voice; otherwise stale shell/service credentials silently re-enable XFYUN.
148
+ for (const key of [
149
+ 'HANDMUX_ASR_PROVIDER',
150
+ 'HANDMUX_ASR_MODE',
151
+ ...voiceProviderRegistry.adapters.flatMap((candidate) => (candidate.fields.map((field) => field.env))),
152
+ ])
153
+ delete env[key];
154
+ const voice = cfg.voice === null || cfg.voice?.enabled === false ? null
155
+ : cfg.voice ?? (cfg.xfyun ? { provider: 'xfyun', providers: { xfyun: cfg.xfyun } } : null);
156
+ if (voice) {
157
+ const adapter = voiceProviderRegistry.get(voice.provider);
158
+ if (adapter) {
159
+ env.HANDMUX_ASR_PROVIDER = adapter.id;
160
+ env.HANDMUX_ASR_MODE = providerMode(adapter, voice.mode);
161
+ const provider = voice.providers[adapter.id];
162
+ for (const field of adapter.fields) {
163
+ const value = provider?.[field.key] || field.defaultValue;
164
+ if (value)
165
+ env[field.env] = value;
166
+ }
167
+ }
152
168
  }
153
169
  const c = spawnChild(processRef.execPath, [SERVER], { env, stdio: ['ignore', 'inherit', 'inherit'] });
154
170
  children.server = c;
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { readSupervisorConfig, supervisorConfigPath, writeSupervisorConfig, } from './state.js';
3
+ import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
3
4
  import { TUNNELS } from './options.js';
4
5
  import { normalizeShortcuts } from '../shortcutConfig.js';
5
6
  const recordOf = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -43,6 +44,28 @@ const stringFields = (value, keys, key) => {
43
44
  }
44
45
  return result;
45
46
  };
47
+ const voiceConfig = (value) => {
48
+ if (value === undefined || value === null)
49
+ return value;
50
+ const record = recordOf(value);
51
+ const providers = recordOf(record?.providers);
52
+ const adapter = voiceProviderRegistry.get(record?.provider);
53
+ if (!record || !adapter || !providers) {
54
+ throw new Error('invalid supervisor config: voice must select a known provider');
55
+ }
56
+ const parsed = {};
57
+ for (const candidate of voiceProviderRegistry.adapters) {
58
+ const config = stringFields(providers[candidate.id], candidate.fields.map((field) => field.key), `voice.providers.${candidate.id}`);
59
+ if (config)
60
+ parsed[candidate.id] = config;
61
+ }
62
+ return {
63
+ ...(record.enabled === false ? { enabled: false } : {}),
64
+ provider: adapter.id,
65
+ mode: providerMode(adapter, record.mode),
66
+ providers: parsed,
67
+ };
68
+ };
46
69
  // The persisted file and the legacy base64 argv are untrusted process boundaries. Parse them before the
47
70
  // supervisor starts so malformed or stale data fails as one explicit configuration error instead of being
48
71
  // used as partially-typed process arguments.
@@ -87,6 +110,7 @@ export function parseSupervisorConfig(value) {
87
110
  const cpolarBin = optionalString(record.cpolarBin, 'cpolarBin');
88
111
  const cpolarRegion = optionalString(record.cpolarRegion, 'cpolarRegion');
89
112
  const vapid = stringFields(record.vapid, ['public', 'private', 'subject'], 'vapid');
113
+ const voice = voiceConfig(record.voice);
90
114
  const xfyun = stringFields(record.xfyun, ['appId', 'apiKey', 'apiSecret'], 'xfyun');
91
115
  if (name !== undefined)
92
116
  config.name = name;
@@ -120,6 +144,8 @@ export function parseSupervisorConfig(value) {
120
144
  config.cpolarRegion = cpolarRegion;
121
145
  if (vapid !== undefined)
122
146
  config.vapid = vapid;
147
+ if (voice !== undefined)
148
+ config.voice = voice;
123
149
  if (xfyun !== undefined)
124
150
  config.xfyun = xfyun;
125
151
  return config;