@goodandready/dsh-voice 0.9.8 → 0.9.10

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/lib/client.js CHANGED
@@ -922,7 +922,6 @@ window.__ModuleLoader__.load({
922
922
  }
923
923
  })
924
924
  }
925
-
926
925
  // ------------------------------------------------------------ recording
927
926
  function teardown(rec) {
928
927
  if (!rec) return
@@ -932,7 +931,6 @@ window.__ModuleLoader__.load({
932
931
  if (rec.analyser) { try { rec.analyser.disconnect() } catch (e) { /* already disconnected */ } }
933
932
  if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
934
933
  }
935
-
936
934
  if (typeof window !== 'undefined') {
937
935
  const onPageUnload = () => {
938
936
  if (voice.rec) teardown(voice.rec)
@@ -943,12 +941,10 @@ window.__ModuleLoader__.load({
943
941
  window.addEventListener('pagehide', onPageUnload)
944
942
  window.addEventListener('beforeunload', onPageUnload)
945
943
  }
946
-
947
944
  function waitStop(recorder) {
948
945
  if (!recorder || recorder.state === 'inactive') return Promise.resolve()
949
946
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
950
947
  }
951
-
952
948
  async function openMic(mode) {
953
949
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
954
950
  throw new Error(t('micUnavailable'))
@@ -982,7 +978,6 @@ window.__ModuleLoader__.load({
982
978
  if (rec.audioCtx.state === 'suspended') { rec.audioCtx.resume().catch(() => {}) }
983
979
  const src = rec.audioCtx.createMediaStreamSource(stream)
984
980
  rec.srcNode = src
985
-
986
981
  let lastNode = src
987
982
  try {
988
983
  const filter = rec.audioCtx.createBiquadFilter()
@@ -993,7 +988,6 @@ window.__ModuleLoader__.load({
993
988
  lastNode = filter
994
989
  rec.filterNode = filter
995
990
  } catch (e) { /* audio filter fallback */ }
996
-
997
991
  rec.analyser = rec.audioCtx.createAnalyser()
998
992
  rec.analyser.fftSize = 128
999
993
  lastNode.connect(rec.analyser)
@@ -1002,7 +996,6 @@ window.__ModuleLoader__.load({
1002
996
  recorder.start()
1003
997
  return rec
1004
998
  }
1005
-
1006
999
  function currentLevel(rec) {
1007
1000
  if (!rec || !rec.analyser) return 0
1008
1001
  const data = new Uint8Array(rec.analyser.frequencyBinCount)
@@ -1010,7 +1003,6 @@ window.__ModuleLoader__.load({
1010
1003
  let sum = 0
1011
1004
  for (let i = 0; i < data.length; i++) sum += data[i]
1012
1005
  const raw = Math.min(1, (sum / data.length / 255) * 2.2)
1013
-
1014
1006
  const gateDb = Number(voice.settings && voice.settings.noiseGateDb !== undefined ? voice.settings.noiseGateDb : -45)
1015
1007
  if (gateDb > -90) {
1016
1008
  const gateAmp = Math.pow(10, gateDb / 20) * 2.2
@@ -1018,11 +1010,9 @@ window.__ModuleLoader__.load({
1018
1010
  }
1019
1011
  return raw
1020
1012
  }
1021
-
1022
1013
  // Cut the current phrase: stop the recorder, send the finished file and
1023
1014
  // immediately start a new recording with the same recorder.
1024
1015
  let dictationQueue = Promise.resolve()
1025
-
1026
1016
  function cutPhrase() {
1027
1017
  const rec = voice.rec
1028
1018
  if (!rec || rec.cutting || rec.closing) return
@@ -1037,6 +1027,7 @@ window.__ModuleLoader__.load({
1037
1027
  return
1038
1028
  }
1039
1029
  stopped.then(async () => {
1030
+ if (rec.cancelled || voice.activeOpId !== opId) return
1040
1031
  const blob = new Blob(rec.chunks, { type: rec.mime })
1041
1032
  rec.chunks = []
1042
1033
  rec.silenceMs = 0
@@ -1107,6 +1098,7 @@ window.__ModuleLoader__.load({
1107
1098
  // Browser recognition instead of file recording. Returns false when the
1108
1099
  // browser cannot do it — then fall back to the normal path.
1109
1100
  function startBrowserLeg(mode, language) {
1101
+ if (voice.settings && voice.settings.localOnly) return false
1110
1102
  if (!browserRecognitionAvailable()) return false
1111
1103
  const finals = []
1112
1104
  voice.caption = ''
@@ -1163,6 +1155,7 @@ window.__ModuleLoader__.load({
1163
1155
 
1164
1156
  function startRecording(mode) {
1165
1157
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
1158
+ voice.activeOpId = (voice.activeOpId || 0) + 1
1166
1159
  if (voice.isTtsSpeaking) {
1167
1160
  if (voice.settings.bargeIn) {
1168
1161
  triggerBargeIn()
@@ -1326,6 +1319,7 @@ window.__ModuleLoader__.load({
1326
1319
  }
1327
1320
 
1328
1321
  function cancelCurrent() {
1322
+ voice.activeOpId = (voice.activeOpId || 0) + 1
1329
1323
  announceVoice('end')
1330
1324
  focusComposer()
1331
1325
  if (voice.browser) {
@@ -1372,6 +1366,8 @@ window.__ModuleLoader__.load({
1372
1366
  const rec = voice.rec
1373
1367
  if (!rec || rec.closing) return
1374
1368
  rec.closing = true
1369
+ voice.activeOpId = (voice.activeOpId || 0) + 1
1370
+ const opId = voice.activeOpId
1375
1371
  const mode = rec.mode
1376
1372
  const stopped = waitStop(rec.recorder)
1377
1373
  try {
@@ -1391,6 +1387,7 @@ window.__ModuleLoader__.load({
1391
1387
  voice.set({ phase: 'processing' })
1392
1388
  try {
1393
1389
  const out = await sendAudio(blob, rec.mime, mode)
1390
+ if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1394
1391
  if (out.command) { runSessionCommand(out.command); return }
1395
1392
  const text = out.text || ''
1396
1393
  const action = out.action || null
@@ -1429,6 +1426,7 @@ window.__ModuleLoader__.load({
1429
1426
  voice.set({ phase: 'idle' })
1430
1427
  }
1431
1428
  } catch (e) {
1429
+ if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1432
1430
  voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
1433
1431
  }
1434
1432
  })
@@ -1980,9 +1978,12 @@ window.__ModuleLoader__.load({
1980
1978
  // The composer needs fresh settings without opening the card.
1981
1979
  Object.assign(voice.settings, {
1982
1980
  beep: !!(data && data.beep),
1981
+ localOnly: !!(data && data.localOnly),
1983
1982
  micDeviceId: String((data && data.micDeviceId) || ''),
1984
1983
  historyLimit: Number(data && data.historyLimit),
1985
1984
  voiceCommands: !!(data && data.voiceCommands),
1985
+ vadSilenceMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.vadSilenceMs) || 700,
1986
+ autoSendMs: Number(data && data.modes && data.modes.message && data.modes.message.autoSendMs) || 4000,
1986
1987
  sendDelayMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.sendDelayMs) || 0,
1987
1988
  stream: !!(data && data.modes && data.modes.dictation && data.modes.dictation.stream),
1988
1989
  streamChunkMs: Number(data && data.modes && data.modes.dictation && data.modes.dictation.streamChunkMs) || 1200,
@@ -2673,7 +2674,8 @@ window.__ModuleLoader__.load({
2673
2674
  const draft = props.draft
2674
2675
  const setTop = props.setTop
2675
2676
  const writable = props.writable
2676
- const [jargonInput, setJargonInput] = React.useState(null)
2677
+ const jargonInput = props.jargonInput
2678
+ const setJargonInput = props.setJargonInput
2677
2679
 
2678
2680
  return React.createElement('div', { className: 'cb-section-card' },
2679
2681
  React.createElement('div', { className: 'cb-section-title' },
@@ -2694,14 +2696,14 @@ window.__ModuleLoader__.load({
2694
2696
  rows: 3,
2695
2697
  disabled: !writable,
2696
2698
  placeholder: t('jargonPlaceholder'),
2697
- value: jargonInput !== null
2699
+ value: jargonInput !== null && jargonInput !== undefined
2698
2700
  ? jargonInput
2699
2701
  : (Array.isArray(draft && draft.jargonDictionary)
2700
2702
  ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
2701
2703
  : ''),
2702
- onChange: (e) => setJargonInput(e.target.value),
2704
+ onChange: (e) => setJargonInput && setJargonInput(e.target.value),
2703
2705
  onBlur: () => {
2704
- if (jargonInput === null) return
2706
+ if (jargonInput === null || jargonInput === undefined) return
2705
2707
  const lines = jargonInput.split('\n')
2706
2708
  const parsed = lines
2707
2709
  .map((line) => {
@@ -2883,6 +2885,7 @@ window.__ModuleLoader__.load({
2883
2885
 
2884
2886
  // Hotkey picker: not a name field — press the key you want.
2885
2887
  const [catching, setCatching] = React.useState(false)
2888
+ const [jargonInput, setJargonInput] = React.useState(null)
2886
2889
  React.useEffect(() => {
2887
2890
  if (!catching) return undefined
2888
2891
  const onKey = (event) => {
@@ -2956,6 +2959,7 @@ window.__ModuleLoader__.load({
2956
2959
  vadSilenceMs: Number(val.dictation && val.dictation.vadSilenceMs) || 700,
2957
2960
  autoSendMs: Number(val.message && val.message.autoSendMs) || 4000,
2958
2961
  beep: !!val.beep,
2962
+ localOnly: !!val.localOnly,
2959
2963
  micDeviceId: String(val.micDeviceId || ''),
2960
2964
  historyLimit: Number(val.historyLimit) || 20,
2961
2965
  voiceCommands: !!val.voiceCommands,
@@ -3099,7 +3103,7 @@ window.__ModuleLoader__.load({
3099
3103
  })
3100
3104
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
3101
3105
 
3102
- setSaved(true)
3106
+ setSaved(true); setJargonInput(null)
3103
3107
  setTimeout(() => setSaved(false), 2000)
3104
3108
  fetchStatus()
3105
3109
  }
@@ -3356,6 +3360,8 @@ window.__ModuleLoader__.load({
3356
3360
  draft: draft,
3357
3361
  setTop: setTop,
3358
3362
  writable: writable,
3363
+ jargonInput: jargonInput,
3364
+ setJargonInput: setJargonInput,
3359
3365
  }),
3360
3366
 
3361
3367
  // 10. Provider Telemetry & Latency Dashboard
package/lib/index.js CHANGED
@@ -92,27 +92,38 @@ export function apply(ctx, baseConfig) {
92
92
  async function startWhisper() {
93
93
  if (startingWhisper) return false
94
94
  startingWhisper = true
95
- const cfg = live()
96
- if (!cfg.autoStart) return false
97
- // Without a model path there is nothing to start: the package cannot know
98
- // where a user's model lives and must not launch a random binary.
99
- if (!cfg.whisperModel) return false
100
- if (await whisperAlive()) {
101
- whisperError = null
102
- return true
103
- }
104
95
  try {
96
+ const cfg = live()
97
+ if (!cfg.autoStart) return false
98
+ // Without a model path there is nothing to start: the package cannot know
99
+ // where a user's model lives and must not launch a random binary.
100
+ if (!cfg.whisperModel) return false
101
+ if (await whisperAlive()) {
102
+ whisperError = null
103
+ return true
104
+ }
105
105
  if (child && typeof child.kill === 'function') {
106
106
  try { child.kill() } catch { /* ignore */ }
107
107
  child = null
108
108
  }
109
- const spec = ctx.shell.resolve({
110
- command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
111
- + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
112
- timeoutMs: 0,
113
- stdoutMaxBytes: 4 * 1024 * 1024,
114
- })
115
- child = ctx.shell.start(spec)
109
+ const spec = typeof ctx.shell?.resolve === 'function'
110
+ ? ctx.shell.resolve({
111
+ command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
112
+ + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
113
+ timeoutMs: 0,
114
+ stdoutMaxBytes: 4 * 1024 * 1024,
115
+ })
116
+ : {
117
+ command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
118
+ + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
119
+ timeoutMs: 0,
120
+ stdoutMaxBytes: 4 * 1024 * 1024,
121
+ }
122
+ child = typeof ctx.shell?.execute === 'function'
123
+ ? ctx.shell.execute(spec)
124
+ : typeof ctx.shell?.start === 'function'
125
+ ? ctx.shell.start(spec)
126
+ : null
116
127
  if (child && typeof child.on === 'function') {
117
128
  child.on('error', (err) => {
118
129
  whisperError = err?.message || String(err)
@@ -123,6 +134,15 @@ export function apply(ctx, baseConfig) {
123
134
  }
124
135
  })
125
136
  }
137
+ if (child && child.done && typeof child.done.then === 'function') {
138
+ child.done.then((res) => {
139
+ if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
140
+ whisperError = `whisper server process exited with code ${res.code}`
141
+ }
142
+ }).catch((err) => {
143
+ whisperError = err?.message || String(err)
144
+ })
145
+ }
126
146
  // Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
127
147
  for (let i = 0; i < 60; i++) {
128
148
  await new Promise((r) => setTimeout(r, 500))
@@ -134,6 +154,10 @@ export function apply(ctx, baseConfig) {
134
154
  whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
135
155
  return false
136
156
  }
157
+ if (child && (child.status === 'exited' || child.status === 'failed')) {
158
+ whisperError = `whisper server process exited prematurely with status ${child.status}`
159
+ return false
160
+ }
137
161
  }
138
162
  whisperError = 'whisper server process started but failed health check on port 8001'
139
163
  return false
@@ -164,14 +188,14 @@ export function apply(ctx, baseConfig) {
164
188
  async function startSensevoice() {
165
189
  if (startingSensevoice) return false
166
190
  startingSensevoice = true
167
- const cfg = live()
168
- if (!cfg.sensevoiceAutostart) return false
169
- if (!cfg.sensevoiceModel) return false
170
- if (await sensevoiceAlive()) {
171
- sensevoiceError = null
172
- return true
173
- }
174
191
  try {
192
+ const cfg = live()
193
+ if (!cfg.sensevoiceAutostart) return false
194
+ if (!cfg.sensevoiceModel) return false
195
+ if (await sensevoiceAlive()) {
196
+ sensevoiceError = null
197
+ return true
198
+ }
175
199
  if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
176
200
  try { sensevoiceChild.kill() } catch { /* ignore */ }
177
201
  sensevoiceChild = null
@@ -180,15 +204,28 @@ export function apply(ctx, baseConfig) {
180
204
  try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
181
205
  const tokensPath = findTokensFile(path.dirname(cfg.sensevoiceModel))
182
206
  const tokensArg = tokensPath ? ` --tokens=${JSON.stringify(tokensPath)}` : ''
183
- const spec = ctx.shell.resolve({
184
- command: `${JSON.stringify(cfg.sensevoiceBin)}`
185
- + ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
186
- + tokensArg
187
- + ` --port=${port} --num-threads=4`,
188
- timeoutMs: 0,
189
- stdoutMaxBytes: 4 * 1024 * 1024,
190
- })
191
- sensevoiceChild = ctx.shell.start(spec)
207
+ const spec = typeof ctx.shell?.resolve === 'function'
208
+ ? ctx.shell.resolve({
209
+ command: `${JSON.stringify(cfg.sensevoiceBin)}`
210
+ + ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
211
+ + tokensArg
212
+ + ` --port=${port} --num-threads=4`,
213
+ timeoutMs: 0,
214
+ stdoutMaxBytes: 4 * 1024 * 1024,
215
+ })
216
+ : {
217
+ command: `${JSON.stringify(cfg.sensevoiceBin)}`
218
+ + ` --sense-voice-model=${JSON.stringify(cfg.sensevoiceModel)}`
219
+ + tokensArg
220
+ + ` --port=${port} --num-threads=4`,
221
+ timeoutMs: 0,
222
+ stdoutMaxBytes: 4 * 1024 * 1024,
223
+ }
224
+ sensevoiceChild = typeof ctx.shell?.execute === 'function'
225
+ ? ctx.shell.execute(spec)
226
+ : typeof ctx.shell?.start === 'function'
227
+ ? ctx.shell.start(spec)
228
+ : null
192
229
  if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
193
230
  sensevoiceChild.on('error', (err) => {
194
231
  sensevoiceError = err?.message || String(err)
@@ -199,6 +236,15 @@ export function apply(ctx, baseConfig) {
199
236
  }
200
237
  })
201
238
  }
239
+ if (sensevoiceChild && sensevoiceChild.done && typeof sensevoiceChild.done.then === 'function') {
240
+ sensevoiceChild.done.then((res) => {
241
+ if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
242
+ sensevoiceError = `sensevoice process exited with code ${res.code}`
243
+ }
244
+ }).catch((err) => {
245
+ sensevoiceError = err?.message || String(err)
246
+ })
247
+ }
202
248
  // Wait for it to become healthy (up to 30s).
203
249
  for (let i = 0; i < 60; i++) {
204
250
  await new Promise((r) => setTimeout(r, 500))
@@ -210,6 +256,10 @@ export function apply(ctx, baseConfig) {
210
256
  sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
211
257
  return false
212
258
  }
259
+ if (sensevoiceChild && (sensevoiceChild.status === 'exited' || sensevoiceChild.status === 'failed')) {
260
+ sensevoiceError = `sensevoice process exited prematurely with status ${sensevoiceChild.status}`
261
+ return false
262
+ }
213
263
  }
214
264
  sensevoiceError = 'sensevoice process started but failed health check'
215
265
  return false
@@ -486,8 +536,41 @@ export function apply(ctx, baseConfig) {
486
536
  liveConfig: live,
487
537
  isAlive: sensevoiceAlive,
488
538
  updateConfig: async (patch) => {
489
- if (scope && typeof scope.patch === 'function') {
490
- scope.patch(patch)
539
+ let saved = false
540
+ const current = live()
541
+ const merged = { ...current, ...patch }
542
+ if (settingsService) {
543
+ let rev
544
+ try {
545
+ rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
546
+ } catch { /* ignore revision lookup errors */ }
547
+
548
+ if (typeof settingsService.replace === 'function') {
549
+ await settingsService.replace(NS, merged, rev)
550
+ saved = true
551
+ } else if (typeof settingsService.update === 'function') {
552
+ await settingsService.update(NS, merged, rev)
553
+ saved = true
554
+ }
555
+ }
556
+
557
+ if (scope) {
558
+ if (typeof scope.replace === 'function') {
559
+ await scope.replace(merged)
560
+ saved = true
561
+ } else if (typeof scope.patch === 'function') {
562
+ await scope.patch(merged)
563
+ saved = true
564
+ } else if (typeof scope.set === 'function') {
565
+ for (const [k, v] of Object.entries(merged)) {
566
+ await scope.set(k, v).catch(() => {})
567
+ }
568
+ saved = true
569
+ }
570
+ }
571
+
572
+ if (!saved) {
573
+ throw new Error('failed to persist settings: neither settingsService nor scope available')
491
574
  }
492
575
  },
493
576
  startSensevoice,
package/lib/polish.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isLoopback } from './http-util.js'
1
2
  // Transcript polish. Pure factory: inject fetch, key resolver, LLM, config.
2
3
  // On any failure return the original text — polish must never block input.
3
4
 
@@ -14,6 +15,15 @@ export function createPolishText({ resolveKey, fetchImpl, getConfig, llm, getAge
14
15
  const enable = modeCfg && (modeCfg.polish === true || modeCfg.polishSend === true)
15
16
  if (!enable || !text) return text
16
17
  const cfg = getConfig() || {}
18
+ if (cfg.localOnly) {
19
+ if (!cfg.polishBaseUrl) return text
20
+ try {
21
+ const u = new URL(cfg.polishBaseUrl)
22
+ if (!isLoopback(u.hostname)) return text
23
+ } catch {
24
+ return text
25
+ }
26
+ }
17
27
  const ask =
18
28
  'Fix the punctuation and spelling of this dictated text and split it into '
19
29
  + 'paragraphs where the speaker changes topic. Remove filler words ("um", "uh", '
package/lib/updater.js CHANGED
@@ -198,23 +198,25 @@ export function checkProfileLock(profileDir) {
198
198
  const lockPath = resolve(profileDir, 'package.json.lock');
199
199
  if (!existsSync(lockPath)) return { locked: false };
200
200
  const pid = readLockPid(lockPath);
201
- if (pid && isProcessAlive(pid)) {
202
- return { locked: true, pid };
203
- }
204
- // Stale lock: PID is dead or unreadable; clean it up to prevent permanent blockage
205
- try {
206
- unlinkSync(lockPath);
207
- return { locked: false, cleanedStale: true };
208
- } catch {
209
- return { locked: false };
201
+ if (pid !== null) {
202
+ if (isProcessAlive(pid)) {
203
+ return { locked: true, pid };
204
+ }
205
+ try {
206
+ unlinkSync(lockPath);
207
+ return { locked: false, cleanedStale: true };
208
+ } catch {
209
+ return { locked: false };
210
+ }
210
211
  }
212
+ return { locked: true, pid: null };
211
213
  }
212
214
 
213
215
  async function installExact(target, packageSpec, options) {
214
216
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
215
217
  const lockCheck = checkProfileLock(target.profileDir);
216
218
  if (lockCheck.locked) {
217
- throw new Error(`Another package installation is currently in progress (PID ${lockCheck.pid}).`);
219
+ throw new Error(`Another package installation is currently in progress${lockCheck.pid ? ` (PID ${lockCheck.pid})` : ''}.`);
218
220
  }
219
221
  await new Promise((resolvePromise, reject) => {
220
222
  const child = spawn(process.execPath, [
@@ -228,28 +230,34 @@ async function installExact(target, packageSpec, options) {
228
230
  env: { ...process.env, NO_COLOR: '1' },
229
231
  });
230
232
  let detail = '';
233
+ let timedOut = false;
234
+ let killTimer = null;
231
235
  child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
232
236
  child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
233
- const timer = setTimeout(() => {
234
- child.kill('SIGTERM');
235
- setTimeout(() => {
236
- if (!child.killed) {
237
- try { child.kill('SIGKILL'); } catch { /* ignore */ }
238
- }
239
- checkProfileLock(target.profileDir);
240
- }, 1000).unref?.();
241
- reject(new Error('Update timed out; use the normal DSH update flow.'));
237
+ const timeoutTimer = setTimeout(() => {
238
+ timedOut = true;
239
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
240
+ killTimer = setTimeout(() => {
241
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
242
+ }, 1000);
242
243
  }, UPDATE_TIMEOUT_MS);
243
244
  child.once('error', error => {
244
- clearTimeout(timer);
245
+ clearTimeout(timeoutTimer);
246
+ if (killTimer) clearTimeout(killTimer);
245
247
  checkProfileLock(target.profileDir);
246
248
  reject(error);
247
249
  });
248
250
  child.once('exit', code => {
249
- clearTimeout(timer);
251
+ clearTimeout(timeoutTimer);
252
+ if (killTimer) clearTimeout(killTimer);
250
253
  checkProfileLock(target.profileDir);
251
- if (code === 0) resolvePromise();
252
- else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
254
+ if (timedOut) {
255
+ reject(new Error('Update timed out; use the normal DSH update flow.'));
256
+ } else if (code === 0) {
257
+ resolvePromise();
258
+ } else {
259
+ reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
260
+ }
253
261
  });
254
262
  });
255
263
  }
@@ -293,7 +301,7 @@ export function registerPluginUpdater(ctx, options) {
293
301
  if (installing || lockStatus.locked) {
294
302
  json(response, 409, {
295
303
  error: lockStatus.locked
296
- ? `Another package installation is currently in progress (PID ${lockStatus.pid}).`
304
+ ? `Another package installation is currently in progress${lockStatus.pid ? ` (PID ${lockStatus.pid})` : ''}.`
297
305
  : 'This plugin is already updating.',
298
306
  });
299
307
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",