@goodandready/dsh-voice 0.9.2 → 0.9.4

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/README.md CHANGED
@@ -189,14 +189,14 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
189
189
  - id: dsh-voice
190
190
  config:
191
191
  dictation:
192
- language: ru
192
+ language: "" # auto-detect by default; or specify "en", "ru", "zh", etc.
193
193
  vadSilenceMs: 700
194
194
  chain:
195
195
  - provider: deepgram
196
196
  - provider: groq
197
197
  - provider: local-whisper
198
198
  message:
199
- language: ru
199
+ language: "" # auto-detect by default; or specify "en", "ru", "zh", etc.
200
200
  autoSendMs: 4000
201
201
  chain:
202
202
  - provider: openai
@@ -206,6 +206,8 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
206
206
  whisperModel: /models/ggml-medium-q8_0.bin
207
207
  ```
208
208
 
209
+ > **Note on language handling**: Setting `language: ""` (default) allows providers to auto-detect the spoken language. Spoken numeral normalization (`wordsToDigits`) and standard Russian tech slang correction (`DEFAULT_JARGON_DICTIONARY`) activate conditionally only when Russian is selected or auto-detected in the transcript, ensuring non-Russian speech is never distorted.
210
+
209
211
  ---
210
212
 
211
213
  ## 🤖 Agent Tool & HTTP API
package/README.ru.md CHANGED
@@ -189,14 +189,14 @@ dsh plugin --profile web add @goodandready/dsh-voice
189
189
  - id: dsh-voice
190
190
  config:
191
191
  dictation:
192
- language: ru
192
+ language: "" # автоопределение по умолчанию; или укажите "ru", "en", "zh" и др.
193
193
  vadSilenceMs: 700
194
194
  chain:
195
195
  - provider: deepgram
196
196
  - provider: groq
197
197
  - provider: local-whisper
198
198
  message:
199
- language: ru
199
+ language: "" # автоопределение по умолчанию; или укажите "ru", "en", "zh" и др.
200
200
  autoSendMs: 4000
201
201
  chain:
202
202
  - provider: openai
@@ -206,6 +206,8 @@ dsh plugin --profile web add @goodandready/dsh-voice
206
206
  whisperModel: /models/ggml-medium-q8_0.bin
207
207
  ```
208
208
 
209
+ > **Примечание по языку**: Значение `language: ""` (по умолчанию) включает автоопределение языка провайдером. Нормализация числительных словами в цифры и стандартный словарь технического сленга применяются адаптивно только при распознавании русской речи, исключая искажение англоязычного текста.
210
+
209
211
  ---
210
212
 
211
213
  ## 🤖 Инструмент агента и HTTP API
package/lib/client.js CHANGED
@@ -773,6 +773,9 @@ window.__ModuleLoader__.load({
773
773
 
774
774
  function keyLabel(name) {
775
775
  if (!name) return t('keyUnset')
776
+ if (typeof name === 'string' && name.includes('+')) {
777
+ return name.split('+').map((p) => keyLabel(p)).join(' + ')
778
+ }
776
779
  if (name === 'Space') return t('keySpace')
777
780
  if (KEY_LABELS[name]) return KEY_LABELS[name]
778
781
  // Drop the Key/Digit prefix from codes like KeyR and Digit5.
@@ -781,10 +784,23 @@ window.__ModuleLoader__.load({
781
784
 
782
785
  // What to store on key press. Pure modifiers are remembered by name:
783
786
  // left and right have different codes but the user means "either".
787
+ // Supports combos like Control+Space or Alt+KeyV.
784
788
  function keyFromEvent(event) {
785
- if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
786
- if (event.code) return event.code
787
- return event.key || ''
789
+ if (!event) return ''
790
+ const modifiers = []
791
+ if (event.ctrlKey && event.key !== 'Control') modifiers.push('Control')
792
+ if (event.altKey && event.key !== 'Alt') modifiers.push('Alt')
793
+ if (event.shiftKey && event.key !== 'Shift') modifiers.push('Shift')
794
+ if (event.metaKey && event.key !== 'Meta') modifiers.push('Meta')
795
+
796
+ const main = ['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)
797
+ ? event.key
798
+ : (event.code || event.key || '')
799
+
800
+ if (modifiers.length > 0 && !['Control', 'Alt', 'Shift', 'Meta'].includes(main)) {
801
+ return modifiers.concat(main).join('+')
802
+ }
803
+ return main
788
804
  }
789
805
 
790
806
  // Announce that the user started speaking.
@@ -1215,18 +1231,56 @@ window.__ModuleLoader__.load({
1215
1231
  // Hotkey: holding a key is easier than aiming the mouse. While the key is
1216
1232
  // down we record; Escape cancels.
1217
1233
  function hotkeyMatches(event, name) {
1234
+ if (!event || !name) return false
1235
+ if (typeof name === 'string' && name.includes('+')) {
1236
+ const parts = name.split('+')
1237
+ const main = parts[parts.length - 1]
1238
+ const needCtrl = parts.includes('Control')
1239
+ const needAlt = parts.includes('Alt')
1240
+ const needShift = parts.includes('Shift')
1241
+ const needMeta = parts.includes('Meta')
1242
+ if (needCtrl && !event.ctrlKey) return false
1243
+ if (needAlt && !event.altKey) return false
1244
+ if (needShift && !event.shiftKey) return false
1245
+ if (needMeta && !event.metaKey) return false
1246
+ return event.code === main || event.key === main
1247
+ }
1218
1248
  if (name === 'Control') return event.key === 'Control'
1219
1249
  if (name === 'Alt') return event.key === 'Alt'
1220
1250
  if (name === 'Shift') return event.key === 'Shift'
1251
+ if (name === 'Meta') return event.key === 'Meta'
1221
1252
  return event.code === name || event.key === name
1222
1253
  }
1223
1254
 
1255
+ function isAllowedInEditable(name) {
1256
+ if (!name) return false
1257
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
1258
+ if (/^F\d{1,2}$/.test(name)) return true
1259
+ if (typeof name === 'string' && name.includes('+')) return true
1260
+ if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
1261
+ return false
1262
+ }
1263
+
1264
+ let activeHotkeyCleanup = null
1265
+
1266
+ function clearGlobalHotkey() {
1267
+ if (typeof activeHotkeyCleanup === 'function') {
1268
+ try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
1269
+ activeHotkeyCleanup = null
1270
+ }
1271
+ if (typeof window !== 'undefined' && typeof window.__dsh_voice_hotkey_cleanup === 'function') {
1272
+ try { window.__dsh_voice_hotkey_cleanup() } catch (_) { /* ignore */ }
1273
+ window.__dsh_voice_hotkey_cleanup = null
1274
+ }
1275
+ }
1276
+
1224
1277
  function installHotkey(ctx, keyName, mode) {
1278
+ clearGlobalHotkey()
1225
1279
  if (typeof document === 'undefined' || !keyName) return () => {}
1226
1280
  const down = (event) => {
1227
1281
  if (event.repeat) return
1228
- // A modifier hotkey does not fight the text field: modifiers do not
1229
- // type. A plain letter key must not be hijacked while typing.
1282
+ // A modifier, function key or combo does not fight the text field:
1283
+ // modifiers/combos do not type plain text. A plain letter key must not be hijacked while typing.
1230
1284
  const target = event.target
1231
1285
  const isEditable = target && (
1232
1286
  target.tagName === 'INPUT' ||
@@ -1234,23 +1288,41 @@ window.__ModuleLoader__.load({
1234
1288
  target.isContentEditable ||
1235
1289
  (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
1236
1290
  )
1237
- if (isEditable && !['Control', 'Alt', 'Shift'].includes(keyName)) return
1291
+ if (isEditable && !isAllowedInEditable(keyName)) return
1238
1292
 
1239
- if (hotkeyMatches(event, keyName)) beginHold(mode)
1293
+ if (hotkeyMatches(event, keyName)) {
1294
+ event.preventDefault()
1295
+ event.stopPropagation()
1296
+ beginHold(mode)
1297
+ }
1240
1298
  }
1241
1299
  const up = (event) => {
1242
- if (hotkeyMatches(event, keyName)) endHold(false)
1243
- else if (event.key === 'Escape' && hold.armed) endHold(true)
1300
+ if (hotkeyMatches(event, keyName)) {
1301
+ event.preventDefault()
1302
+ endHold(false)
1303
+ } else if (event.key === 'Escape' && hold.armed) {
1304
+ event.preventDefault()
1305
+ endHold(true)
1306
+ }
1244
1307
  }
1245
1308
  const blur = () => { if (hold.armed) endHold(true) }
1246
1309
  document.addEventListener('keydown', down, true)
1247
1310
  document.addEventListener('keyup', up, true)
1248
1311
  window.addEventListener('blur', blur)
1249
- return () => {
1312
+ const cleanup = () => {
1250
1313
  document.removeEventListener('keydown', down, true)
1251
1314
  document.removeEventListener('keyup', up, true)
1252
1315
  window.removeEventListener('blur', blur)
1316
+ if (activeHotkeyCleanup === cleanup) activeHotkeyCleanup = null
1317
+ if (typeof window !== 'undefined' && window.__dsh_voice_hotkey_cleanup === cleanup) {
1318
+ window.__dsh_voice_hotkey_cleanup = null
1319
+ }
1253
1320
  }
1321
+ activeHotkeyCleanup = cleanup
1322
+ if (typeof window !== 'undefined') {
1323
+ window.__dsh_voice_hotkey_cleanup = cleanup
1324
+ }
1325
+ return cleanup
1254
1326
  }
1255
1327
 
1256
1328
  function cancelCurrent() {
@@ -1941,6 +2013,7 @@ window.__ModuleLoader__.load({
1941
2013
  alive = false
1942
2014
  window.removeEventListener('dsh-voice:settings-saved', reload)
1943
2015
  dispose()
2016
+ clearGlobalHotkey()
1944
2017
  }
1945
2018
  }, 'dsh-voice: hold hotkey')
1946
2019
 
@@ -2219,11 +2292,17 @@ window.__ModuleLoader__.load({
2219
2292
  if (event.key === 'Escape') { setCatching(false); return }
2220
2293
  const chosen = keyFromEvent(event)
2221
2294
  setDraft((d) => Object.assign({}, d || {}, { hotkey: chosen }))
2295
+ voice.hotkey = chosen || ''
2296
+ if (chosen) {
2297
+ installHotkey(ctx, chosen, 'message')
2298
+ } else {
2299
+ clearGlobalHotkey()
2300
+ }
2222
2301
  setCatching(false)
2223
2302
  }
2224
2303
  document.addEventListener('keydown', onKey, true)
2225
2304
  return () => document.removeEventListener('keydown', onKey, true)
2226
- }, [catching])
2305
+ }, [catching, ctx])
2227
2306
 
2228
2307
  React.useEffect(() => {
2229
2308
  let alive = true
@@ -2562,7 +2641,14 @@ window.__ModuleLoader__.load({
2562
2641
  }
2563
2642
 
2564
2643
  const d = draft || {}
2644
+ voice.hotkey = d.hotkey || ''
2645
+ if (voice.hotkey) {
2646
+ installHotkey(ctx, voice.hotkey, 'message')
2647
+ } else {
2648
+ clearGlobalHotkey()
2649
+ }
2565
2650
  Object.assign(voice.settings, {
2651
+ hotkey: d.hotkey || '',
2566
2652
  vadSilenceMs: Number(d.dictation && d.dictation.vadSilenceMs) || 700,
2567
2653
  autoSendMs: Number(d.message && d.message.autoSendMs) || 4000,
2568
2654
  beep: !!d.beep,
@@ -2611,7 +2697,11 @@ window.__ModuleLoader__.load({
2611
2697
  }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
2612
2698
  React.createElement('button', {
2613
2699
  type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
2614
- disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
2700
+ disabled: !writable, onClick: () => {
2701
+ setDraft((d) => Object.assign({}, d || {}, { hotkey: '' }))
2702
+ voice.hotkey = ''
2703
+ clearGlobalHotkey()
2704
+ },
2615
2705
  }, '×'),
2616
2706
  ),
2617
2707
  React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
@@ -3137,7 +3227,7 @@ window.__ModuleLoader__.load({
3137
3227
  React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
3138
3228
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
3139
3229
  React.createElement('span', { className: 'cb-badge cb-badge-ok' },
3140
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.2')
3230
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.4')
3141
3231
  ),
3142
3232
  updaterStatus && updaterStatus.updateAvailable
3143
3233
  ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
package/lib/http-util.js CHANGED
@@ -46,14 +46,52 @@ export function isLoopback(addr) {
46
46
  */
47
47
  export function isTrustedCaller(req) {
48
48
  if (!req) return false
49
- const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
50
- if (isLoopback(remote)) return true
51
49
 
50
+ // 1. Browser security headers: immediately reject cross-site and same-site requests (#117, #173)
52
51
  const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
53
52
  if (secSite === 'cross-site' || secSite === 'same-site') return false
54
53
 
54
+ // 2. If Origin header is present, it must strictly match the Host header
55
55
  const host = String(req.headers?.host || '').toLowerCase()
56
56
  const origin = String(req.headers?.origin || '').trim().toLowerCase()
57
+ if (origin) {
58
+ try {
59
+ const u = new URL(origin)
60
+ if (u.host !== host) return false
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ // 3. If Referer header is present, it must strictly match the Host header
67
+ const referer = String(req.headers?.referer || '').trim().toLowerCase()
68
+ if (referer) {
69
+ try {
70
+ const u = new URL(referer)
71
+ if (u.host !== host) return false
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ // 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
78
+ // Cross-origin simple form submissions cannot set application/json without a CORS preflight.
79
+ const method = String(req.method || 'GET').toUpperCase()
80
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
81
+ const contentType = String(req.headers?.['content-type'] || '').toLowerCase()
82
+ const isJson = contentType.startsWith('application/json')
83
+ const isOctet = contentType.startsWith('application/octet-stream')
84
+ if (!isJson && !isOctet) {
85
+ return false
86
+ }
87
+ }
88
+
89
+ // 5. Loopback remote address is trusted when not rejected by browser headers above
90
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
91
+ if (isLoopback(remote)) return true
92
+
93
+ // 6. Non-loopback requests must be verified same-origin
94
+ if (secSite === 'same-origin') return true
57
95
  if (origin) {
58
96
  try {
59
97
  const u = new URL(origin)
@@ -61,10 +99,6 @@ export function isTrustedCaller(req) {
61
99
  } catch { /* invalid url */ }
62
100
  return false
63
101
  }
64
-
65
- if (secSite === 'same-origin') return true
66
-
67
- const referer = String(req.headers?.referer || '').trim().toLowerCase()
68
102
  if (referer) {
69
103
  try {
70
104
  const u = new URL(referer)
package/lib/index.js CHANGED
@@ -84,7 +84,8 @@ const BaseConfig = z.object({
84
84
  chain: z.array(ChainEntry)
85
85
  .default([{ provider: 'deepgram', model: '' }, { provider: 'groq', model: '' }, { provider: 'local-whisper', model: '' }])
86
86
  .description('Fallback chain for dictation. Speed matters more than accuracy here.'),
87
- language: z.string().default('ru'),
87
+ language: z.string().default('')
88
+ .description('Recognition language code for dictation (e.g. "en", "ru", "zh", or empty string for auto-detection).'),
88
89
  vadSilenceMs: z.number().default(700)
89
90
  .description('Silence longer than this ends a phrase and sends the chunk.'),
90
91
  sendDelayMs: z.number().default(0)
@@ -102,7 +103,8 @@ const BaseConfig = z.object({
102
103
  chain: z.array(ChainEntry)
103
104
  .default([{ provider: 'groq', model: '' }, { provider: 'hf', model: '' }, { provider: 'local-whisper', model: '' }])
104
105
  .description('Fallback chain for voice messages. Accuracy matters more than speed.'),
105
- language: z.string().default('ru'),
106
+ language: z.string().default('')
107
+ .description('Recognition language code for messages (e.g. "en", "ru", "zh", or empty string for auto-detection).'),
106
108
  autoSendMs: z.number().default(4000)
107
109
  .description('Cancel window before the recognized text is sent to the agent.'),
108
110
  sessionCommands: z.boolean().default(false)
@@ -531,10 +533,15 @@ export function apply(ctx, baseConfig) {
531
533
  if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
532
534
  if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
533
535
  const cfg = live()
536
+ const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
534
537
  let raw
535
538
  try {
536
- raw = await readBody(req, cfg.maxFileBytes + 1024 * 1024)
539
+ raw = await readBody(req, maxBodyBytes)
537
540
  } catch (e) {
541
+ if (e && e.message === 'body too large') {
542
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
543
+ return
544
+ }
538
545
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
539
546
  }
540
547
  let payload
@@ -605,7 +612,7 @@ export function apply(ctx, baseConfig) {
605
612
  + 'Use for voice messages, recordings, interviews.',
606
613
  parameters: {
607
614
  file_path: { type: 'string', required: true, description: 'Absolute path to the audio file (wav, mp3, m4a, ogg, flac, webm).' },
608
- language: { type: 'string', description: `Recognition language code. Default: ${(baseConfig && baseConfig.message && baseConfig.message.language) || 'ru'}.` },
615
+ language: { type: 'string', description: `Recognition language code (e.g. "en", "ru", "zh", or empty string for auto-detection). Default: ${(baseConfig && baseConfig.message && baseConfig.message.language) || 'auto'}.` },
609
616
  },
610
617
  output: {
611
618
  schema: {
@@ -637,12 +644,13 @@ export function apply(ctx, baseConfig) {
637
644
  const bytes = await readFile(real)
638
645
  const sniffedMime = validateAudioContent(bytes)
639
646
  const mime = sniffedMime || MIME_BY_EXT[path.extname(real).toLowerCase()] || 'audio/wav'
640
- const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
647
+ const modeCfg = { ...cfg.message, language: String(args.language !== undefined ? args.language : cfg.message.language) }
641
648
  let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
642
649
  if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
643
650
  const out = raw
644
651
  if (cfg.normalizeTranscript && out && out.text) {
645
652
  out.text = normalizePhrase(out.text, {
653
+ lang: modeCfg.language,
646
654
  digits: true, capSentences: true, commaSpacing: true, trailingPeriod: true,
647
655
  })
648
656
  }
package/lib/normalize.js CHANGED
@@ -107,7 +107,14 @@ function cachedJargonRegex(from) {
107
107
  return re
108
108
  }
109
109
 
110
- export function applyJargonDictionary(text, customReplacements = []) {
110
+ export function isRussianLang(lang, text = '') {
111
+ const l = String(lang || '').trim().toLowerCase()
112
+ if (l === 'ru' || l.startsWith('ru-') || l.startsWith('ru_')) return true
113
+ if (l === '' || l === 'auto') return /[а-яё]/i.test(String(text || ''))
114
+ return false
115
+ }
116
+
117
+ export function applyJargonDictionary(text, customReplacements = [], options = {}) {
111
118
  if (!text || typeof text !== 'string') return text || ''
112
119
  let result = text
113
120
  for (const item of customReplacements || []) {
@@ -116,9 +123,11 @@ export function applyJargonDictionary(text, customReplacements = []) {
116
123
  re.lastIndex = 0
117
124
  result = result.replace(re, item.to)
118
125
  }
119
- for (const item of DEFAULT_JARGON_REGEX) {
120
- item.re.lastIndex = 0
121
- result = result.replace(item.re, item.to)
126
+ if (options.defaultJargon !== false) {
127
+ for (const item of DEFAULT_JARGON_REGEX) {
128
+ item.re.lastIndex = 0
129
+ result = result.replace(item.re, item.to)
130
+ }
122
131
  }
123
132
  return result
124
133
  }
@@ -160,9 +169,13 @@ export function extractVoiceActions(text) {
160
169
  export function normalizePhrase(text, opts = {}) {
161
170
  let out = String(text || '').trim()
162
171
  if (!out) return out
163
- if (opts.digits) out = wordsToDigits(out)
172
+ const lang = opts.lang ?? opts.language
173
+ const isRu = opts.isRussian ?? (lang !== undefined ? isRussianLang(lang, out) : true)
174
+ if (opts.digits && isRu) out = wordsToDigits(out)
164
175
  if (opts.commaSpacing) out = fixCommaSpacing(out)
165
- if (opts.jargon) out = applyJargonDictionary(out, opts.customJargon)
176
+ if (opts.jargon) {
177
+ out = applyJargonDictionary(out, opts.customJargon, { defaultJargon: isRu })
178
+ }
166
179
  if (opts.capSentences) out = capitalizeSentences(out)
167
180
  if (opts.trailingPeriod) out = ensureTrailingPeriod(out)
168
181
  return out
package/lib/updater.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Implements canonical DSH plugin-updater specification.
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
- import { existsSync, readFileSync } from 'node:fs';
5
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { homedir } from 'node:os';
8
8
  import { basename, dirname, isAbsolute, resolve } from 'node:path';
@@ -169,12 +169,57 @@ export async function getUpdateStatus(options, target = runtime()) {
169
169
  };
170
170
  }
171
171
 
172
+ export function readLockPid(lockPath) {
173
+ if (!existsSync(lockPath)) return null;
174
+ try {
175
+ const raw = readFileSync(lockPath, 'utf8').trim();
176
+ if (/^\d+$/.test(raw)) {
177
+ return parseInt(raw, 10);
178
+ }
179
+ const parsed = JSON.parse(raw);
180
+ if (parsed && typeof parsed.pid === 'number') {
181
+ return parsed.pid;
182
+ }
183
+ } catch { /* ignore unparseable lock file */ }
184
+ return null;
185
+ }
186
+
187
+ export function isProcessAlive(pid) {
188
+ if (!pid || typeof pid !== 'number' || pid <= 0) return false;
189
+ try {
190
+ process.kill(pid, 0);
191
+ return true;
192
+ } catch (e) {
193
+ return e && e.code === 'EPERM';
194
+ }
195
+ }
196
+
197
+ export function checkProfileLock(profileDir) {
198
+ const lockPath = resolve(profileDir, 'package.json.lock');
199
+ if (!existsSync(lockPath)) return { locked: false };
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 };
210
+ }
211
+ }
212
+
172
213
  async function installExact(target, packageSpec, options) {
173
214
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
215
+ const lockCheck = checkProfileLock(target.profileDir);
216
+ if (lockCheck.locked) {
217
+ throw new Error(`Another package installation is currently in progress (PID ${lockCheck.pid}).`);
218
+ }
174
219
  await new Promise((resolvePromise, reject) => {
175
220
  const child = spawn(process.execPath, [
176
221
  target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
177
- '--config.minimumReleaseAge=0', packageSpec,
222
+ packageSpec,
178
223
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
179
224
  ], {
180
225
  cwd: target.profileDir,
@@ -186,12 +231,23 @@ async function installExact(target, packageSpec, options) {
186
231
  child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
187
232
  child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
188
233
  const timer = setTimeout(() => {
189
- child.kill();
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?.();
190
241
  reject(new Error('Update timed out; use the normal DSH update flow.'));
191
242
  }, UPDATE_TIMEOUT_MS);
192
- child.once('error', error => { clearTimeout(timer); reject(error); });
243
+ child.once('error', error => {
244
+ clearTimeout(timer);
245
+ checkProfileLock(target.profileDir);
246
+ reject(error);
247
+ });
193
248
  child.once('exit', code => {
194
249
  clearTimeout(timer);
250
+ checkProfileLock(target.profileDir);
195
251
  if (code === 0) resolvePromise();
196
252
  else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
197
253
  });
@@ -233,8 +289,13 @@ export function registerPluginUpdater(ctx, options) {
233
289
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' });
234
290
  return;
235
291
  }
236
- if (installing) {
237
- json(response, 409, { error: 'This plugin is already updating.' });
292
+ const lockStatus = checkProfileLock(target.profileDir);
293
+ if (installing || lockStatus.locked) {
294
+ json(response, 409, {
295
+ error: lockStatus.locked
296
+ ? `Another package installation is currently in progress (PID ${lockStatus.pid}).`
297
+ : 'This plugin is already updating.',
298
+ });
238
299
  return;
239
300
  }
240
301
  installing = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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",