@hxnnxs/opencode-voice 0.1.6 → 0.1.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.1.8 - 2026-06-21
6
+
7
+ ### Fixed
8
+
9
+ - Added a managed Windows recorder install: the plugin can now download, unpack, cache, and probe `ffmpeg.exe` itself before recording, so Windows voice input no longer depends on `ffmpeg-static` lifecycle scripts, npm recovery installs, or a user-installed recorder on `PATH`.
10
+ - Extended `opencode-voice doctor` diagnostics to prepare and report the managed Windows recorder path, manifest, install error, and probe result.
11
+ - Made managed native binary replacement retry-safe on Windows for both recorder and `whisper-cli` engine installs.
12
+
13
+ ## 0.1.7 - 2026-06-17
14
+
15
+ ### Fixed
16
+
17
+ - Hardened Windows ffmpeg resolution to handle executable-resolution edge cases (extensionless bundled binary paths and local module fallback), so recorder startup can use the actual resolved binary path.
18
+ - Improved diagnostics to print exact recorder command paths and quick per-command probe results in `opencode-voice doctor` output.
19
+
5
20
  ## 0.1.6 - 2026-06-17
6
21
 
7
22
  ### Fixed
package/README.md CHANGED
@@ -112,7 +112,7 @@ Planned sidecar models:
112
112
  | -------- | ------ |
113
113
  | Linux | one-command engine/model install; recording uses `arecord`, `ffmpeg`, or `sox` |
114
114
  | macOS | one-command engine/model install; recording uses `ffmpeg` AVFoundation until the native recorder sidecar ships |
115
- | Windows | one-command engine/model install; recording uses `ffmpeg` with DirectShow (system or bundled `ffmpeg` fallback) |
115
+ | Windows | one-command engine/model/recorder install; recording uses DirectShow through a managed cached `ffmpeg.exe`, with system/bundled ffmpeg fallback |
116
116
 
117
117
  ### Architecture
118
118
 
@@ -128,7 +128,7 @@ Files:
128
128
  - `index.js` - TUI plugin entrypoint, commands, dialogs, keymap layer
129
129
  - `lib/models.js` - model registry, cache paths, default settings
130
130
  - `lib/download.js` - resumable model download and SHA256 verification
131
- - `lib/engine.js` - recorder selection and `whisper-cli` transcription
131
+ - `lib/engine.js` - recorder selection, managed Windows recorder install, and `whisper-cli` transcription
132
132
  - `lib/engines.js` - managed native engine download, status, import, and removal
133
133
  - `bin/opencode-voice.js` - install wrapper and diagnostics CLI
134
134
 
@@ -32,6 +32,34 @@ function packageName() {
32
32
  return process.env.OPENCODE_VOICE_PACKAGE || manifest.name || "opencode-voice";
33
33
  }
34
34
 
35
+ function probeCommand(command, args = ["-version"]) {
36
+ if (!command) return { ok: false, message: "missing" };
37
+ try {
38
+ const result = spawnSync(command, args, {
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ timeout: 2000,
42
+ });
43
+
44
+ if (result.error) {
45
+ return { ok: false, message: result.error.message };
46
+ }
47
+
48
+ if (typeof result.status === "number" && result.status !== 0) {
49
+ return {
50
+ ok: false,
51
+ message: `exit ${result.status}`,
52
+ stderr: (result.stderr || "").trim(),
53
+ };
54
+ }
55
+
56
+ const output = `${result.stdout || ""}${result.stderr || ""}`;
57
+ return { ok: true, versionLine: output.split("\n").filter(Boolean)[0] || "" };
58
+ } catch (error) {
59
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
60
+ }
61
+ }
62
+
35
63
  async function runtime() {
36
64
  const [engine, models, engines] = await Promise.all([import("../lib/engine.js"), import("../lib/models.js"), import("../lib/engines.js")]);
37
65
  return { ...engine, ...models, ...engines };
@@ -39,17 +67,41 @@ async function runtime() {
39
67
 
40
68
  async function doctor() {
41
69
  const {
42
- commandExists,
70
+ ensureManagedRecorder,
71
+ resolveCommand,
43
72
  getAudioDir,
44
73
  getCacheDir,
45
74
  getEngineStatus,
46
75
  getModelsDir,
76
+ getRecorderStatus,
47
77
  listMicrophones,
48
78
  probeEngine,
49
79
  } = await runtime();
50
80
 
81
+ const json = hasFlag("--json");
82
+ let recorderInstall = null;
83
+ let recorderInstallError = "";
84
+ if (process.platform === "win32") {
85
+ try {
86
+ recorderInstall = await ensureManagedRecorder(
87
+ {},
88
+ {},
89
+ json ? {} : { onProgress: printRecorderProgress, onRetry: printRecorderRetry },
90
+ );
91
+ } catch (error) {
92
+ recorderInstallError = error instanceof Error ? error.message : String(error);
93
+ }
94
+ }
95
+
96
+ const recorderOptions = process.platform === "win32" && recorderInstall?.resolvedBinary
97
+ ? { ffmpeg: recorderInstall.resolvedBinary, skipFfmpegStaticInstall: true }
98
+ : {};
51
99
  const engine = getEngineStatus("whisper.cpp");
52
100
  const probe = engine.resolvedBinary ? await probeEngine("whisper.cpp", engine.resolvedBinary) : { ok: false, message: "missing binary" };
101
+ const recorder = getRecorderStatus(recorderOptions);
102
+ const ffmpeg = recorderInstall?.resolvedBinary || resolveCommand("ffmpeg", recorderOptions);
103
+ const arecord = resolveCommand("arecord", recorderOptions);
104
+ const sox = resolveCommand("sox", recorderOptions);
53
105
  const payload = {
54
106
  platform: `${process.platform}-${process.arch}`,
55
107
  cacheDir: getCacheDir(),
@@ -57,15 +109,24 @@ async function doctor() {
57
109
  recordingsDir: getAudioDir(),
58
110
  engine,
59
111
  probe,
112
+ recorder,
60
113
  recorders: {
61
- ffmpeg: commandExists("ffmpeg"),
62
- arecord: commandExists("arecord"),
63
- sox: commandExists("sox"),
114
+ ffmpeg,
115
+ arecord,
116
+ sox,
117
+ ffmpegPresent: Boolean(ffmpeg),
118
+ arecordPresent: Boolean(arecord),
119
+ soxPresent: Boolean(sox),
120
+ ffmpegProbe: probeCommand(ffmpeg),
121
+ arecordProbe: probeCommand(arecord, ["--help"]),
122
+ soxProbe: probeCommand(sox, ["--help"]),
123
+ managedInstall: recorderInstall,
124
+ managedInstallError: recorderInstallError,
64
125
  },
65
- microphones: listMicrophones(),
126
+ microphones: listMicrophones(recorderOptions),
66
127
  };
67
128
 
68
- if (hasFlag("--json")) {
129
+ if (json) {
69
130
  console.log(JSON.stringify(payload, null, 2));
70
131
  } else {
71
132
  console.log(
@@ -80,13 +141,19 @@ async function doctor() {
80
141
  `Managed engine dir: ${engine.managedDir}`,
81
142
  `whisper-cli: ${engine.resolvedBinary || "missing"}`,
82
143
  `Probe: ${probe.ok ? "ok" : probe.message}`,
83
- `Recorders: ffmpeg=${payload.recorders.ffmpeg ? "yes" : "no"}, arecord=${payload.recorders.arecord ? "yes" : "no"}, sox=${payload.recorders.sox ? "yes" : "no"}`,
144
+ `Recorder source: ${recorder.source}`,
145
+ `Managed recorder dir: ${recorder.managedDir}`,
146
+ `Managed recorder installed: ${recorder.managedInstalled ? "yes" : "no"}`,
147
+ `Managed recorder version: ${recorder.manifest?.version || "missing"}`,
148
+ `Managed recorder probe: ${payload.recorders.ffmpegProbe.ok ? "ok" : payload.recorders.ffmpegProbe.message}`,
149
+ ...(recorderInstallError ? [`Managed recorder error: ${recorderInstallError}`] : []),
150
+ `Recorders: ffmpeg=${payload.recorders.ffmpegPresent ? "yes" : "no"}${payload.recorders.ffmpeg ? ` (${payload.recorders.ffmpeg})` : ""}, arecord=${payload.recorders.arecordPresent ? "yes" : "no"}${payload.recorders.arecord ? ` (${payload.recorders.arecord})` : ""}, sox=${payload.recorders.soxPresent ? "yes" : "no"}${payload.recorders.sox ? ` (${payload.recorders.sox})` : ""}`,
84
151
  `Microphones: ${payload.microphones.join(", ")}`,
85
152
  ].join("\n"),
86
153
  );
87
154
  }
88
155
 
89
- if (!engine.resolvedBinary || !probe.ok) process.exitCode = 1;
156
+ if (!engine.resolvedBinary || !probe.ok || (process.platform === "win32" && !payload.recorders.ffmpegProbe.ok)) process.exitCode = 1;
90
157
  }
91
158
 
92
159
  async function engineCommand() {
@@ -160,6 +227,21 @@ function printEngineRetry({ error, nextAttempt, attempts }) {
160
227
  console.warn(`engine retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`);
161
228
  }
162
229
 
230
+ function printRecorderProgress(progress) {
231
+ const label = {
232
+ downloading: "download",
233
+ decompressing: "unpack",
234
+ probing: "probe",
235
+ done: "done",
236
+ }[progress.state] || progress.state || "recorder";
237
+ const percent = Number.isFinite(progress.percent) ? `${Math.round(progress.percent)}%` : "";
238
+ if (progress.state === "downloading" || progress.state === "done") console.log(`recorder ${label} ${percent}`.trim());
239
+ }
240
+
241
+ function printRecorderRetry({ error, nextAttempt, attempts }) {
242
+ console.warn(`recorder retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`);
243
+ }
244
+
163
245
  async function installCommand() {
164
246
  const pluginArgs = args.filter((arg) => arg !== "--no-engine");
165
247
  const spawnOptions = { stdio: "inherit" };
@@ -177,6 +259,13 @@ async function installCommand() {
177
259
  const engine = await installManagedEngine("whisper.cpp", {}, {}, { onProgress: printEngineProgress, onRetry: printEngineRetry });
178
260
  console.log(`Managed voice engine ready: ${engine.managedBinary}`);
179
261
  }
262
+
263
+ if (process.platform === "win32") {
264
+ const { ensureManagedRecorder } = await runtime();
265
+ console.log("Installing managed Windows recorder...");
266
+ const recorder = await ensureManagedRecorder({}, {}, { onProgress: printRecorderProgress, onRetry: printRecorderRetry });
267
+ console.log(`Managed Windows recorder ready: ${recorder.managedBinary || recorder.resolvedBinary}`);
268
+ }
180
269
  }
181
270
 
182
271
  if (command === "install") await installCommand();
package/docs/README.es.md CHANGED
@@ -110,7 +110,7 @@ Modelos sidecar planeados:
110
110
  | ---------- | ------ |
111
111
  | Linux | instalación engine/model en una orden; la grabación usa `arecord`, `ffmpeg` o `sox` |
112
112
  | macOS | instalación engine/model en una orden; la grabación usa `ffmpeg` AVFoundation hasta el native recorder sidecar |
113
- | Windows | instalación one-command de engine/model; grabación con `ffmpeg` + DirectShow (desde ffmpeg del sistema o fallback incluido) |
113
+ | Windows | instalación one-command de engine/model/recorder; grabación con DirectShow mediante `ffmpeg.exe` managed en caché, con fallback a ffmpeg del sistema/incluido |
114
114
 
115
115
  ### Arquitectura
116
116
 
@@ -126,7 +126,7 @@ Archivos:
126
126
  - `index.js` - entrada TUI, comandos, dialogs, keymap layer
127
127
  - `lib/models.js` - registry de modelos, cache paths, default settings
128
128
  - `lib/download.js` - descarga resumible y verificación SHA256
129
- - `lib/engine.js` - selección de recorder y transcripción con `whisper-cli`
129
+ - `lib/engine.js` - selección de recorder, instalación managed del recorder Windows y transcripción con `whisper-cli`
130
130
  - `lib/engines.js` - descarga, estado, importación y eliminación de managed native engine
131
131
  - `bin/opencode-voice.js` - install wrapper y diagnostics CLI
132
132
 
package/docs/README.ru.md CHANGED
@@ -110,7 +110,7 @@ Hold-to-talk отключен по умолчанию, потому что termi
110
110
  | --------- | ------ |
111
111
  | Linux | one-command engine/model install; запись использует `arecord`, `ffmpeg` или `sox` |
112
112
  | macOS | one-command engine/model install; запись использует `ffmpeg` AVFoundation до native recorder sidecar |
113
- | Windows | one-command engine/model install; запись через `ffmpeg` + DirectShow (через системный ffmpeg или встроенный fallback) |
113
+ | Windows | one-command engine/model/recorder install; запись через DirectShow и managed cached `ffmpeg.exe`, с fallback на системный/встроенный ffmpeg |
114
114
 
115
115
  ### Архитектура
116
116
 
@@ -126,7 +126,7 @@ Hold-to-talk отключен по умолчанию, потому что termi
126
126
  - `index.js` - TUI entrypoint, команды, dialogs, keymap layer
127
127
  - `lib/models.js` - registry моделей, cache paths, default settings
128
128
  - `lib/download.js` - resumable download и SHA256 verification
129
- - `lib/engine.js` - выбор recorder и transcription через `whisper-cli`
129
+ - `lib/engine.js` - выбор recorder, managed Windows recorder install и transcription через `whisper-cli`
130
130
  - `lib/engines.js` - managed native engine download, status, import и removal
131
131
  - `bin/opencode-voice.js` - install wrapper и diagnostics CLI
132
132
 
package/docs/README.zh.md CHANGED
@@ -110,7 +110,7 @@ ctrl+r -> 停止、转写并插入文本
110
110
  | ------- | ---- |
111
111
  | Linux | 一条命令安装 engine/model;录音使用 `arecord`、`ffmpeg` 或 `sox` |
112
112
  | macOS | 一条命令安装 engine/model;native recorder sidecar 发布前使用 `ffmpeg` AVFoundation |
113
- | Windows | 下载 engine/model 一条命令完成;通过 `ffmpeg` + DirectShow 录音(使用系统 ffmpeg 或内置备用) |
113
+ | Windows | 一条命令安装 engine/model/recorder;通过 managed cached `ffmpeg.exe` + DirectShow 录音,并保留系统/内置 ffmpeg 备用 |
114
114
 
115
115
  ### 架构
116
116
 
@@ -126,7 +126,7 @@ ctrl+r -> 停止、转写并插入文本
126
126
  - `index.js` - TUI 插件入口、命令、dialogs、keymap layer
127
127
  - `lib/models.js` - 模型 registry、cache paths、default settings
128
128
  - `lib/download.js` - 可续传下载和 SHA256 校验
129
- - `lib/engine.js` - recorder 选择和 `whisper-cli` 转写
129
+ - `lib/engine.js` - recorder 选择、managed Windows recorder 安装和 `whisper-cli` 转写
130
130
  - `lib/engines.js` - managed native engine 下载、状态、导入和移除
131
131
  - `bin/opencode-voice.js` - install wrapper 和 diagnostics CLI
132
132
 
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { MODELS, DEFAULT_SETTINGS, PLUGIN_ID, formatSize, getCacheDir, getModel, getModelPath, isModelDownloaded, isModelFilePresent } from "./lib/models.js";
2
2
  import { downloadModel } from "./lib/download.js";
3
- import { VoiceRuntime, commandExists, listMicrophones, resolveCommand } from "./lib/engine.js";
3
+ import { VoiceRuntime, ensureManagedRecorder, getRecorderStatus, listMicrophones, probeRecorder, resolveCommand } from "./lib/engine.js";
4
4
  import { getEngineStatus, importManagedEngine, installManagedEngine, probeEngine, removeManagedEngine } from "./lib/engines.js";
5
5
 
6
6
  const KV = {
@@ -141,6 +141,33 @@ function renderEngineInstallStatus(ctx, progress = {}) {
141
141
  );
142
142
  }
143
143
 
144
+ function renderRecorderInstallStatus(ctx, progress = {}) {
145
+ const percent = Math.max(0, Math.min(100, progress.percent || 0));
146
+ const state = {
147
+ downloading: "Downloading Windows recorder",
148
+ decompressing: "Unpacking ffmpeg",
149
+ probing: "Checking ffmpeg",
150
+ done: "Windows recorder ready",
151
+ }[progress.state] || "Preparing Windows recorder";
152
+ const attempt = progress.attempts > 1 ? `${progress.attempt} of ${progress.attempts}` : "single pass";
153
+
154
+ setDialog(ctx, "xlarge", () =>
155
+ ctx.api.ui.DialogAlert({
156
+ title: "Installing Windows recorder",
157
+ message: [
158
+ "ffmpeg DirectShow",
159
+ "",
160
+ state,
161
+ progressLine(percent),
162
+ "",
163
+ progress.total ? `${formatBytes(progress.downloaded || 0)} of ${formatBytes(progress.total)}` : "Fetching recorder binary",
164
+ `Attempt ${attempt}`,
165
+ "This is downloaded once into the managed opencode-voice cache.",
166
+ ].join("\n"),
167
+ }),
168
+ );
169
+ }
170
+
144
171
  function modelStatus(model, options, settings) {
145
172
  if (!model.implemented) return "planned";
146
173
  if (isModelFilePresent(model, options, settings) && !isModelDownloaded(model, options, settings)) return "needs verification";
@@ -216,6 +243,30 @@ async function ensureEngineReady(ctx, settings) {
216
243
  return true;
217
244
  }
218
245
 
246
+ async function ensureRecorderReady(ctx, settings) {
247
+ if (process.platform !== "win32") return true;
248
+
249
+ const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
250
+ const current = getRecorderStatus(commandOptions, settings);
251
+ if (current.resolvedBinary) {
252
+ const probe = await probeRecorder(current.resolvedBinary);
253
+ if (probe.ok) return true;
254
+ }
255
+
256
+ renderRecorderInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: 1, attempts: 5 });
257
+ toast(ctx.api, "Installing local Windows recorder...");
258
+ await ensureManagedRecorder(commandOptions, settings, {
259
+ retries: 5,
260
+ onProgress: (progress) => renderRecorderInstallStatus(ctx, progress),
261
+ onRetry: ({ error, nextAttempt, attempts }) => {
262
+ renderRecorderInstallStatus(ctx, { state: "downloading", downloaded: 0, total: 0, percent: 0, attempt: nextAttempt, attempts });
263
+ toast(ctx.api, `Recorder install retry ${nextAttempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`, "warning");
264
+ },
265
+ });
266
+ toast(ctx.api, "Windows recorder installed", "success");
267
+ return true;
268
+ }
269
+
219
270
  function showModelPicker(ctx, firstRun = false) {
220
271
  const settings = readSettings(ctx.api.kv);
221
272
  setDialog(ctx, "large", () =>
@@ -325,8 +376,9 @@ function showLanguagePicker(ctx) {
325
376
 
326
377
  function showMicrophonePicker(ctx) {
327
378
  const settings = readSettings(ctx.api.kv);
379
+ const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
328
380
  const placeholder = process.platform === "win32" ? "default, audio=default, \"Microphone (Name)\"" : "default, hw:0,0, pulse, :0, ...";
329
- const devices = listMicrophones();
381
+ const devices = listMicrophones(commandOptions);
330
382
  setDialog(ctx, "large", () =>
331
383
  ctx.api.ui.DialogSelect({
332
384
  title: "Voice microphone",
@@ -360,15 +412,20 @@ function showMicrophonePicker(ctx) {
360
412
  function showDiagnostics(ctx) {
361
413
  const settings = readSettings(ctx.api.kv);
362
414
  const model = getModel(settings.model);
363
- const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir };
415
+ const commandOptions = { ...ctx.options, downloadDir: settings.downloadDir, skipFfmpegStaticInstall: true };
364
416
  const whisperCli = resolveCommand("whisper-cli", commandOptions);
365
417
  const ffmpeg = resolveCommand("ffmpeg", commandOptions);
366
418
  const arecord = resolveCommand("arecord", commandOptions);
367
419
  const sox = resolveCommand("sox", commandOptions);
368
420
  const engine = getEngineStatus("whisper.cpp", ctx.options, settings);
421
+ const recorder = getRecorderStatus(commandOptions, settings);
369
422
  const lines = [
370
423
  `Platform: ${process.platform}-${process.arch}`,
371
424
  `Recorder: ffmpeg=${ffmpeg ? "yes" : "no"}${ffmpeg ? ` (${ffmpeg})` : ""}, arecord=${arecord ? "yes" : "no"}, sox=${sox ? "yes" : "no"}`,
425
+ `Recorder source: ${recorder.source}`,
426
+ `Managed recorder dir: ${recorder.managedDir}`,
427
+ `Managed recorder installed: ${recorder.managedInstalled ? "yes" : "no"}`,
428
+ `Managed recorder version: ${recorder.manifest?.version || "missing"}`,
372
429
  `Engine: ${engine.id}`,
373
430
  `Engine source: ${engine.source}`,
374
431
  `whisper-cli: ${whisperCli || "missing"}`,
@@ -665,8 +722,9 @@ async function startVoice(ctx, submit = false, hold = false) {
665
722
 
666
723
  try {
667
724
  await ensureEngineReady(ctx, settings);
725
+ await ensureRecorderReady(ctx, settings);
668
726
  } catch (error) {
669
- showError(ctx, "Engine install failed", error);
727
+ showError(ctx, "Voice runtime setup failed", error);
670
728
  return;
671
729
  }
672
730
 
package/lib/engine.js CHANGED
@@ -4,17 +4,48 @@ import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
- import { ensureDir } from "./download.js";
8
- import { getAudioDir, getEnginesDir, getModelPath } from "./models.js";
7
+ import { createGunzip } from "node:zlib";
8
+ import { Readable, Transform } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
10
+ import { ensureDir, replaceFile, sha256 } from "./download.js";
11
+ import { getAudioDir, getCacheDir, getEnginesDir, getModelPath } from "./models.js";
9
12
 
10
13
  const require = createRequire(import.meta.url);
11
14
  const FFMPEG_STATIC_PACKAGE = "ffmpeg-static@^5.2.0";
15
+ const FFMPEG_STATIC_RELEASE = "b6.1.1";
16
+ const FFMPEG_STATIC_BASE_URL = "https://github.com/eugeneware/ffmpeg-static/releases/download";
17
+ const DEFAULT_RECORDER_DOWNLOAD_RETRIES = 5;
12
18
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
19
  let ffmpegStaticPathCache;
14
20
  let ffmpegStaticInstallAttempted = false;
15
21
 
16
22
  const RECORDING_MIN_BYTES = 44;
17
23
 
24
+ function sleep(ms) {
25
+ return new Promise((resolve) => setTimeout(resolve, ms));
26
+ }
27
+
28
+ function normalizeArch(value) {
29
+ if (value === "amd64") return "x64";
30
+ if (value === "x86") return "ia32";
31
+ if (value === "aarch64") return "arm64";
32
+ return value;
33
+ }
34
+
35
+ function windowsFfmpegPlatform(options = {}) {
36
+ const platform = options.platform || process.platform;
37
+ const arch = normalizeArch(options.arch || process.arch);
38
+ return { platform, arch, key: `${platform}-${arch}` };
39
+ }
40
+
41
+ function isWindows(options = {}) {
42
+ return (options.platform || process.platform) === "win32";
43
+ }
44
+
45
+ function withDownloadDir(options = {}, settings = {}) {
46
+ return { ...options, downloadDir: settings.downloadDir || options.downloadDir };
47
+ }
48
+
18
49
  function isExecutable(file) {
19
50
  try {
20
51
  fs.accessSync(file, fs.constants.X_OK);
@@ -24,11 +55,354 @@ function isExecutable(file) {
24
55
  }
25
56
  }
26
57
 
27
- function executableNames(command) {
58
+ function executableExtensions(options = {}) {
59
+ if (!isWindows(options)) return [""];
60
+ return (process.env.PATHEXT || ".EXE;.CMD;.BAT")
61
+ .split(";")
62
+ .map((entry) => entry.trim().toLowerCase())
63
+ .filter(Boolean)
64
+ .filter((entry) => entry.startsWith("."));
65
+ }
66
+
67
+ function resolveExecutable(file, options = {}) {
68
+ const normalized = path.resolve(file);
69
+ if (isExecutable(normalized)) return normalized;
70
+
71
+ if (path.extname(normalized) || !isWindows(options)) {
72
+ return "";
73
+ }
74
+
75
+ for (const ext of executableExtensions(options)) {
76
+ const candidate = `${normalized}${ext}`;
77
+ if (isExecutable(candidate)) return candidate;
78
+ }
79
+
80
+ return "";
81
+ }
82
+
83
+ function managedFfmpegDir(options = {}, settings = {}) {
84
+ const platform = windowsFfmpegPlatform(options);
85
+ return path.join(getCacheDir(options, settings), "recorders", "ffmpeg-static", platform.key);
86
+ }
87
+
88
+ export function getManagedRecorderBinary(options = {}, settings = {}) {
89
+ const platform = windowsFfmpegPlatform(options);
90
+ return path.join(managedFfmpegDir(options, settings), platform.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
91
+ }
92
+
93
+ export function getManagedRecorderManifestPath(options = {}, settings = {}) {
94
+ return path.join(managedFfmpegDir(options, settings), "manifest.json");
95
+ }
96
+
97
+ export function readManagedRecorderManifest(options = {}, settings = {}) {
98
+ const file = getManagedRecorderManifestPath(options, settings);
99
+ if (!fs.existsSync(file)) return null;
100
+ try {
101
+ return JSON.parse(fs.readFileSync(file, "utf8"));
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function temporaryBinaryPath(binaryPath) {
108
+ const extension = path.extname(binaryPath);
109
+ if (!extension) return `${binaryPath}.tmp-${process.pid}`;
110
+ return path.join(path.dirname(binaryPath), `${path.basename(binaryPath, extension)}.tmp-${process.pid}${extension}`);
111
+ }
112
+
113
+ function selectManagedRecorderAsset(options = {}) {
114
+ const platform = windowsFfmpegPlatform(options);
115
+ if (platform.platform !== "win32") throw new Error(`Managed ffmpeg recorder is only available for Windows, got ${platform.key}`);
116
+
117
+ const assetArch = platform.arch === "arm64" ? "x64" : platform.arch;
118
+ if (!new Set(["x64", "ia32"]).has(assetArch)) throw new Error(`No managed ffmpeg recorder asset for ${platform.key}`);
119
+
120
+ const assetKey = `${platform.platform}-${assetArch}`;
121
+ return {
122
+ platform,
123
+ assetKey,
124
+ emulated: assetArch !== platform.arch,
125
+ url: `${FFMPEG_STATIC_BASE_URL}/${FFMPEG_STATIC_RELEASE}/ffmpeg-${assetKey}.gz`,
126
+ };
127
+ }
128
+
129
+ function recorderSource(resolved, managedBinary) {
130
+ if (!resolved) return "missing";
131
+ if (path.resolve(resolved) === path.resolve(managedBinary)) return "managed";
132
+ return "system-or-bundled";
133
+ }
134
+
135
+ function recorderDownloadTimeoutMs(options = {}, hooks = {}) {
136
+ return Number(options.recorderDownloadTimeoutMs || options.downloadTimeoutMs || hooks.timeoutMs || 120000);
137
+ }
138
+
139
+ async function fetchRecorderWithTimeout(url, timeoutMs) {
140
+ const controller = new AbortController();
141
+ let timer;
142
+ const reset = () => {
143
+ clearTimeout(timer);
144
+ timer = setTimeout(() => controller.abort(), timeoutMs);
145
+ };
146
+ const clear = () => clearTimeout(timer);
147
+ reset();
148
+
149
+ try {
150
+ const response = await fetch(url, { signal: controller.signal });
151
+ return { response, signal: controller.signal, reset, clear };
152
+ } catch (error) {
153
+ clear();
154
+ if (error?.name === "AbortError") throw new Error(`Recorder download timed out after ${Math.round(timeoutMs / 1000)}s`);
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ function recorderDownloadError(error, url, timeoutMs) {
160
+ if (error?.name === "AbortError" || error?.code === "ABORT_ERR") {
161
+ return new Error(`Recorder download timed out or stalled from ${url} after ${Math.round(timeoutMs / 1000)}s`);
162
+ }
163
+ return error;
164
+ }
165
+
166
+ async function downloadRecorderAsset(sourceUrl, compressedFile, hooks = {}, attempt = 1, attempts = 1, options = {}) {
167
+ if (sourceUrl.startsWith("file://") || !/^https?:\/\//.test(sourceUrl)) {
168
+ const source = sourceUrl.startsWith("file://") ? new URL(sourceUrl) : path.resolve(sourceUrl);
169
+ const stat = await fs.promises.stat(source);
170
+ let downloaded = 0;
171
+ hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: 0, attempt, attempts });
172
+ const progress = new Transform({
173
+ transform(chunk, _encoding, callback) {
174
+ downloaded += chunk.length ?? chunk.byteLength ?? 0;
175
+ hooks.onProgress?.({ state: "downloading", downloaded, total: stat.size, percent: stat.size ? (downloaded / stat.size) * 100 : 0, attempt, attempts });
176
+ callback(null, chunk);
177
+ },
178
+ });
179
+ await pipeline(fs.createReadStream(source), progress, fs.createWriteStream(compressedFile));
180
+ return;
181
+ }
182
+
183
+ const timeoutMs = recorderDownloadTimeoutMs(options, hooks);
184
+ const request = await fetchRecorderWithTimeout(sourceUrl, timeoutMs);
185
+ const response = request.response;
186
+
187
+ try {
188
+ if (!response.ok) throw new Error(`Recorder download failed from ${sourceUrl}: HTTP ${response.status}`);
189
+ const contentLength = Number(response.headers.get("content-length") || 0);
190
+ let downloaded = 0;
191
+ hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: 0, attempt, attempts });
192
+
193
+ const body = response.body?.getReader ? Readable.fromWeb(response.body) : response.body;
194
+ if (!body) throw new Error("Recorder download failed: empty response body");
195
+
196
+ const progress = new Transform({
197
+ transform(chunk, _encoding, callback) {
198
+ request.reset();
199
+ downloaded += chunk.length ?? chunk.byteLength ?? 0;
200
+ hooks.onProgress?.({ state: "downloading", downloaded, total: contentLength, percent: contentLength ? (downloaded / contentLength) * 100 : 0, attempt, attempts });
201
+ callback(null, chunk);
202
+ },
203
+ });
204
+
205
+ try {
206
+ await pipeline(body, progress, fs.createWriteStream(compressedFile), { signal: request.signal });
207
+ } catch (error) {
208
+ throw recorderDownloadError(error, sourceUrl, timeoutMs);
209
+ }
210
+ } finally {
211
+ request.clear();
212
+ }
213
+ }
214
+
215
+ export async function probeRecorder(binaryPath, options = {}) {
216
+ if (!binaryPath) return { ok: false, message: "missing binary" };
217
+
218
+ return new Promise((resolve) => {
219
+ let output = "";
220
+ let settled = false;
221
+ let timer;
222
+ const finish = (value) => {
223
+ if (settled) return;
224
+ settled = true;
225
+ clearTimeout(timer);
226
+ resolve(value);
227
+ };
228
+
229
+ let proc;
230
+ try {
231
+ proc = spawn(binaryPath, ["-version"], {
232
+ stdio: ["ignore", "pipe", "pipe"],
233
+ env: {
234
+ ...process.env,
235
+ PATH: [path.dirname(binaryPath), process.env.PATH].filter(Boolean).join(path.delimiter),
236
+ ...options.env,
237
+ },
238
+ });
239
+ } catch (error) {
240
+ finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
241
+ return;
242
+ }
243
+
244
+ timer = setTimeout(() => {
245
+ try {
246
+ proc.kill("SIGKILL");
247
+ } catch {}
248
+ finish({ ok: false, message: "probe timed out" });
249
+ }, options.timeoutMs || 10000);
250
+
251
+ proc.stdout.on("data", (chunk) => {
252
+ output += chunk.toString();
253
+ });
254
+ proc.stderr.on("data", (chunk) => {
255
+ output += chunk.toString();
256
+ });
257
+ proc.on("error", (error) => finish({ ok: false, message: error.message }));
258
+ proc.on("exit", (code) => {
259
+ const versionLine = output.split(/\r?\n/).filter(Boolean)[0] || "";
260
+ const ok = code === 0 && /ffmpeg version/i.test(output);
261
+ finish({ ok, message: ok ? "ok" : `unexpected ffmpeg probe output${typeof code === "number" ? ` (exit ${code})` : ""}`, versionLine });
262
+ });
263
+ });
264
+ }
265
+
266
+ async function writeManagedRecorderManifest(managedBinary, source, options = {}, settings = {}) {
267
+ const hash = await sha256(managedBinary);
268
+ const stat = await fs.promises.stat(managedBinary);
269
+ const manifest = {
270
+ schema: "opencode-voice.recorder-install.v1",
271
+ id: "ffmpeg",
272
+ kind: "cli",
273
+ command: "ffmpeg",
274
+ platform: windowsFfmpegPlatform(options).key,
275
+ version: FFMPEG_STATIC_RELEASE,
276
+ source,
277
+ files: [{ path: path.basename(managedBinary), sha256: hash, size: stat.size }],
278
+ installedAt: new Date().toISOString(),
279
+ };
280
+
281
+ await fs.promises.writeFile(getManagedRecorderManifestPath(options, settings), `${JSON.stringify(manifest, null, 2)}\n`);
282
+ return manifest;
283
+ }
284
+
285
+ export function getRecorderStatus(options = {}, settings = {}) {
286
+ const commandOptions = withDownloadDir(options, settings);
287
+ const managedDir = managedFfmpegDir(options, settings);
288
+ const managedBinary = getManagedRecorderBinary(options, settings);
289
+ const manifest = readManagedRecorderManifest(options, settings);
290
+ const resolvedBinary = resolveCommand("ffmpeg", commandOptions);
291
+
292
+ return {
293
+ id: "ffmpeg",
294
+ command: "ffmpeg",
295
+ platform: windowsFfmpegPlatform(options).key,
296
+ supported: isWindows(options),
297
+ managedDir,
298
+ managedBinary,
299
+ managedInstalled: isExecutable(managedBinary),
300
+ manifest,
301
+ resolvedBinary,
302
+ source: recorderSource(resolvedBinary, managedBinary),
303
+ };
304
+ }
305
+
306
+ export async function installManagedRecorder(options = {}, settings = {}, hooks = {}) {
307
+ const commandOptions = withDownloadDir(options, settings);
308
+ const asset = selectManagedRecorderAsset(commandOptions);
309
+ const managedDir = managedFfmpegDir(commandOptions, settings);
310
+ const managedBinary = getManagedRecorderBinary(commandOptions, settings);
311
+ if (!hooks.force && fs.existsSync(managedBinary)) {
312
+ const probe = await probeRecorder(managedBinary);
313
+ if (probe.ok) return { manifest: readManagedRecorderManifest(commandOptions, settings), managedBinary, probe, skipped: true };
314
+ }
315
+
316
+ await ensureDir(managedDir);
317
+ const compressedFile = path.join(managedDir, `${path.basename(asset.url)}.download`);
318
+ const tmpBinary = temporaryBinaryPath(managedBinary);
319
+ const attempts = Number(options.recorderDownloadRetries || hooks.retries || DEFAULT_RECORDER_DOWNLOAD_RETRIES);
320
+ let lastError;
321
+
322
+ for (let attempt = 1; attempt <= attempts; attempt++) {
323
+ try {
324
+ await fs.promises.unlink(compressedFile).catch(() => {});
325
+ await fs.promises.unlink(tmpBinary).catch(() => {});
326
+ await downloadRecorderAsset(asset.url, compressedFile, hooks, attempt, attempts, commandOptions);
327
+
328
+ const compressedSize = fs.existsSync(compressedFile) ? fs.statSync(compressedFile).size : 0;
329
+ hooks.onProgress?.({ state: "decompressing", downloaded: compressedSize, total: compressedSize, percent: 100, attempt, attempts });
330
+ await pipeline(fs.createReadStream(compressedFile), createGunzip(), fs.createWriteStream(tmpBinary));
331
+ await fs.promises.chmod(tmpBinary, 0o755);
332
+
333
+ hooks.onProgress?.({ state: "probing", downloaded: fs.statSync(tmpBinary).size, total: fs.statSync(tmpBinary).size, percent: 100, attempt, attempts });
334
+ const probe = await probeRecorder(tmpBinary);
335
+ if (!probe.ok) throw new Error(`Recorder probe failed: ${probe.message}`);
336
+
337
+ await replaceFile(tmpBinary, managedBinary);
338
+ await fs.promises.unlink(compressedFile).catch(() => {});
339
+ const manifest = await writeManagedRecorderManifest(
340
+ managedBinary,
341
+ {
342
+ type: "ffmpeg-static-release",
343
+ release: FFMPEG_STATIC_RELEASE,
344
+ url: asset.url,
345
+ assetPlatform: asset.assetKey,
346
+ emulated: asset.emulated,
347
+ },
348
+ commandOptions,
349
+ settings,
350
+ );
351
+ const installedSize = fs.statSync(managedBinary).size;
352
+ hooks.onProgress?.({ state: "done", downloaded: installedSize, total: installedSize, percent: 100, attempt, attempts });
353
+ return { manifest, managedBinary, probe, skipped: false };
354
+ } catch (error) {
355
+ lastError = error;
356
+ await fs.promises.unlink(tmpBinary).catch(() => {});
357
+ if (attempt >= attempts) break;
358
+ hooks.onRetry?.({ error, attempt, attempts, nextAttempt: attempt + 1 });
359
+ await sleep(Math.min(1000 * attempt, 3000));
360
+ }
361
+ }
362
+
363
+ throw lastError;
364
+ }
365
+
366
+ export async function ensureManagedRecorder(options = {}, settings = {}, hooks = {}) {
367
+ const commandOptions = withDownloadDir(options, settings);
368
+ if (!isWindows(commandOptions)) return { ...getRecorderStatus(commandOptions, settings), ok: true, skipped: true, unsupported: true };
369
+
370
+ const existingOptions = { ...commandOptions, skipFfmpegStaticInstall: true };
371
+ const current = getRecorderStatus(existingOptions, settings);
372
+ if (current.resolvedBinary) {
373
+ const probe = await probeRecorder(current.resolvedBinary);
374
+ if (probe.ok) return { ...current, ok: true, probe, skipped: true };
375
+ }
376
+
377
+ try {
378
+ const installed = await installManagedRecorder(commandOptions, settings, hooks);
379
+ return { ...getRecorderStatus(commandOptions, settings), ...installed, ok: true };
380
+ } catch (error) {
381
+ const resolvedBinary = resolveRecorderFallback(existingOptions, settings);
382
+ if (resolvedBinary) {
383
+ const probe = await probeRecorder(resolvedBinary);
384
+ if (probe.ok) {
385
+ return {
386
+ ...getRecorderStatus(commandOptions, settings),
387
+ resolvedBinary,
388
+ source: recorderSource(resolvedBinary, getManagedRecorderBinary(commandOptions, settings)),
389
+ ok: true,
390
+ fallback: true,
391
+ managedError: error instanceof Error ? error.message : String(error),
392
+ probe,
393
+ };
394
+ }
395
+ }
396
+
397
+ throw new Error(`Windows ffmpeg recorder install failed: ${error instanceof Error ? error.message : String(error)}`);
398
+ }
399
+ }
400
+
401
+ function executableNames(command, options = {}) {
28
402
  const names = [command];
29
403
  if (path.extname(command)) return names;
30
404
 
31
- if (process.platform === "win32") {
405
+ if (isWindows(options)) {
32
406
  const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT")
33
407
  .split(";")
34
408
  .map((item) => item.trim().toLowerCase())
@@ -39,19 +413,25 @@ function executableNames(command) {
39
413
  return [...new Set(names)];
40
414
  }
41
415
 
42
- function bundledFfmpegPath() {
416
+ function bundledFfmpegPath(options = {}) {
43
417
  if (process.platform !== "win32") return "";
44
418
  if (ffmpegStaticPathCache !== undefined) return ffmpegStaticPathCache;
45
419
 
46
420
  const resolveFromDependency = () => {
47
421
  try {
48
422
  const candidate = require("ffmpeg-static");
49
- return typeof candidate === "string" ? path.resolve(candidate) : "";
423
+ if (typeof candidate !== "string") return "";
424
+ return resolveExecutable(candidate);
50
425
  } catch {
51
426
  return "";
52
427
  }
53
428
  };
54
429
 
430
+ const resolveFromLocalModule = () => {
431
+ const fallback = path.join(PLUGIN_ROOT, "node_modules", "ffmpeg-static", "ffmpeg");
432
+ return resolveExecutable(fallback);
433
+ };
434
+
55
435
  const installDependency = () => {
56
436
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
57
437
  try {
@@ -66,13 +446,17 @@ function bundledFfmpegPath() {
66
446
  };
67
447
 
68
448
  let candidate = resolveFromDependency();
69
- if (!candidate && !ffmpegStaticInstallAttempted) {
449
+ if (!candidate) candidate = resolveFromLocalModule();
450
+ if (!candidate && !ffmpegStaticInstallAttempted && !options.skipFfmpegStaticInstall) {
70
451
  ffmpegStaticInstallAttempted = true;
71
452
  if (installDependency()) {
72
453
  candidate = resolveFromDependency();
73
454
  }
74
455
  }
75
456
 
457
+ if (!candidate) candidate = resolveFromLocalModule();
458
+ if (!candidate && options.skipFfmpegStaticInstall) return "";
459
+
76
460
  ffmpegStaticPathCache = candidate || "";
77
461
  return ffmpegStaticPathCache;
78
462
  }
@@ -99,16 +483,19 @@ function candidateCommands(command, options = {}) {
99
483
  }
100
484
 
101
485
  if (command === "ffmpeg") {
102
- const bundled = bundledFfmpegPath();
486
+ if (options.ffmpeg) candidates.push(options.ffmpeg);
487
+ if (process.env.OPENCODE_VOICE_FFMPEG) candidates.push(process.env.OPENCODE_VOICE_FFMPEG);
488
+ if (isWindows(options)) candidates.push(getManagedRecorderBinary(options, options));
489
+ const bundled = bundledFfmpegPath(options);
103
490
  if (bundled) candidates.push(bundled);
104
491
  }
105
492
 
106
493
  const bundledDir = getBundledEngineDir(command, options);
107
494
  if (bundledDir) {
108
- for (const name of executableNames(command)) candidates.push(path.join(bundledDir, name));
495
+ for (const name of executableNames(command, options)) candidates.push(path.join(bundledDir, name));
109
496
  }
110
497
 
111
- candidates.push(...executableNames(command));
498
+ candidates.push(...executableNames(command, options));
112
499
 
113
500
  const fallbackDirs = [
114
501
  path.join(os.homedir(), ".local", "bin"),
@@ -119,13 +506,13 @@ function candidateCommands(command, options = {}) {
119
506
  "/bin",
120
507
  ];
121
508
 
122
- if (process.platform === "win32") {
509
+ if (isWindows(options)) {
123
510
  const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
124
511
  fallbackDirs.unshift(path.join(localAppData, "opencode-voice", "bin"));
125
512
  }
126
513
 
127
514
  for (const dir of fallbackDirs) {
128
- for (const name of executableNames(command)) candidates.push(path.join(dir, name));
515
+ for (const name of executableNames(command, options)) candidates.push(path.join(dir, name));
129
516
  }
130
517
 
131
518
  return [...new Set(candidates.filter(Boolean))];
@@ -134,13 +521,34 @@ function candidateCommands(command, options = {}) {
134
521
  export function resolveCommand(command, options = {}) {
135
522
  for (const candidate of candidateCommands(command, options)) {
136
523
  if (looksLikePath(candidate)) {
137
- if (isExecutable(candidate)) return candidate;
524
+ const resolved = resolveExecutable(candidate, options);
525
+ if (resolved) return resolved;
526
+ continue;
527
+ }
528
+
529
+ for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
530
+ const file = path.join(dir, candidate);
531
+ const resolved = resolveExecutable(file, options);
532
+ if (resolved) return resolved;
533
+ }
534
+ }
535
+
536
+ return null;
537
+ }
538
+
539
+ function resolveRecorderFallback(options = {}, settings = {}) {
540
+ const managedBinary = path.resolve(getManagedRecorderBinary(options, settings));
541
+ for (const candidate of candidateCommands("ffmpeg", options)) {
542
+ if (looksLikePath(candidate)) {
543
+ const resolved = resolveExecutable(candidate, options);
544
+ if (resolved && path.resolve(resolved) !== managedBinary) return resolved;
138
545
  continue;
139
546
  }
140
547
 
141
548
  for (const dir of (process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
142
549
  const file = path.join(dir, candidate);
143
- if (isExecutable(file)) return file;
550
+ const resolved = resolveExecutable(file, options);
551
+ if (resolved && path.resolve(resolved) !== managedBinary) return resolved;
144
552
  }
145
553
  }
146
554
 
@@ -181,9 +589,9 @@ function parseWindowsMicrophones(stderr) {
181
589
  return [...devices].filter(Boolean);
182
590
  }
183
591
 
184
- export function listMicrophones() {
185
- const arecordCommand = resolveCommand("arecord");
186
- const ffmpegCommand = resolveCommand("ffmpeg");
592
+ export function listMicrophones(options = {}) {
593
+ const arecordCommand = resolveCommand("arecord", options);
594
+ const ffmpegCommand = resolveCommand("ffmpeg", options);
187
595
 
188
596
  if (process.platform === "linux" && arecordCommand) {
189
597
  const result = spawnSync(arecordCommand, ["-L"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -381,9 +789,15 @@ export class VoiceRuntime {
381
789
 
382
790
  const dir = getAudioDir(this.options, settings);
383
791
  await ensureDir(dir);
792
+ const recorderSettings = { ...this.options, ...settings, downloadDir: settings.downloadDir || this.options.downloadDir };
793
+ const recorderReady = await ensureManagedRecorder(recorderSettings, settings).catch((error) => {
794
+ if (isWindows(recorderSettings)) throw error;
795
+ return null;
796
+ });
797
+ if (recorderReady?.resolvedBinary) recorderSettings.ffmpeg = recorderReady.resolvedBinary;
384
798
 
385
799
  const file = path.join(dir, `voice-${Date.now()}.wav`);
386
- const recorders = buildRecorders(file, settings);
800
+ const recorders = buildRecorders(file, recorderSettings);
387
801
  const errors = [];
388
802
  this.recordingError = "";
389
803
 
package/lib/engines.js CHANGED
@@ -282,8 +282,7 @@ export async function installManagedEngine(engineId = ENGINE.id, options = {}, s
282
282
  const probe = await probeEngine(engineId, tmpBinary);
283
283
  if (!probe.ok) throw new Error(`Engine probe failed: ${probe.message}`);
284
284
 
285
- await fs.promises.unlink(managedBinary).catch(() => {});
286
- await fs.promises.rename(tmpBinary, managedBinary);
285
+ await replaceFile(tmpBinary, managedBinary);
287
286
  await fs.promises.unlink(compressedFile).catch(() => {});
288
287
  const manifest = await writeManifest(
289
288
  engineId,
@@ -332,8 +331,7 @@ export async function importManagedEngine(engineId = ENGINE.id, sourcePath, opti
332
331
  throw new Error(`Imported binary failed probe: ${probe.message}`);
333
332
  }
334
333
 
335
- await fs.promises.unlink(managedBinary).catch(() => {});
336
- await fs.promises.rename(tmp, managedBinary);
334
+ await replaceFile(tmp, managedBinary);
337
335
  const manifest = await writeManifest(engineId, managedBinary, { type: "local-file", path: source }, options);
338
336
  return { manifest, managedBinary };
339
337
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hxnnxs/opencode-voice",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Local voice input plugin for OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",