@vividcodeai/embeddedcowork 0.0.5 → 0.0.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.
Files changed (39) hide show
  1. package/dist/config/schema.js +1 -0
  2. package/dist/events/bus.js +2 -0
  3. package/dist/index.js +30 -0
  4. package/dist/opencode-downloader.js +295 -0
  5. package/dist/opencode-paths.js +167 -0
  6. package/dist/server/http-server.js +2 -0
  7. package/dist/server/routes/opencode-status.js +10 -0
  8. package/dist/workspaces/manager.js +68 -56
  9. package/package.json +3 -1
  10. package/public/assets/{ChangesTab-C2DJXDf9.js → ChangesTab-C4_zdV74.js} +2 -2
  11. package/public/assets/{DiffToolbar-De-3SRCF.js → DiffToolbar-BsUPigM5.js} +1 -1
  12. package/public/assets/{FilesTab-BuQ00MEc.js → FilesTab-Drvo30nM.js} +2 -2
  13. package/public/assets/{GitChangesTab-D9bf2jkM.js → GitChangesTab-BhV6qTfP.js} +2 -2
  14. package/public/assets/{SplitFilePanel-B-3h60o2.js → SplitFilePanel-BBrs329I.js} +1 -1
  15. package/public/assets/{StatusTab-D5s19fRN.js → StatusTab-DfYWlTSX.js} +1 -1
  16. package/public/assets/{bundle-full-CdNbUxmo.js → bundle-full-CAZqmV2E.js} +1 -1
  17. package/public/assets/{diff-viewer-B1l_VZc1.js → diff-viewer--UIZ5GEn.js} +1 -1
  18. package/public/assets/{index-C9Tl2tHH.js → index--0EL0_Tu.js} +2 -2
  19. package/public/assets/{index-BErmCqgL.js → index-BLouhjer.js} +1 -1
  20. package/public/assets/{index-ixx_g9gD.js → index-CgjnEdBS.js} +1 -1
  21. package/public/assets/{index-BQeBs108.js → index-ChCPoe9m.js} +1 -1
  22. package/public/assets/{index-B2LsA7hD.js → index-Co-dJ-Xs.js} +1 -1
  23. package/public/assets/index-Ctcq8Rhl.css +1 -0
  24. package/public/assets/{index-BqQARTCd.js → index-Da0V-sLI.js} +1 -1
  25. package/public/assets/{index-BKvZBimW.js → index-hOT6sqTO.js} +1 -1
  26. package/public/assets/{loading-CQjaT4lJ.js → loading-CvW03p4Z.js} +1 -1
  27. package/public/assets/main-ByuKWHRz.js +53 -0
  28. package/public/assets/{markdown-D5eIdNMf.js → markdown-BBFC6uy4.js} +3 -3
  29. package/public/assets/{monaco-viewer-9Byc1Kpy.js → monaco-viewer-DnczYBfh.js} +8 -8
  30. package/public/assets/{todo-uxdyLWei.js → todo-BLpUqy61.js} +1 -1
  31. package/public/assets/{tool-call-C_JEoVSV.js → tool-call-B4Xz6FbC.js} +3 -3
  32. package/public/assets/{unified-picker-ePaJEYDm.js → unified-picker-BrzM_sH6.js} +1 -1
  33. package/public/assets/{wrap-text-S8HH4qqP.js → wrap-text-B1i7zgLk.js} +1 -1
  34. package/public/index.html +4 -4
  35. package/public/loading.html +4 -4
  36. package/public/sw.js +1 -1
  37. package/public/ui-version.json +1 -1
  38. package/public/assets/index-DElsPAzQ.css +0 -1
  39. package/public/assets/main-C1yBw4P8.js +0 -53
@@ -24,6 +24,7 @@ const PreferencesSchema = z
24
24
  autoCleanupBlankSessions: z.boolean().default(true),
25
25
  listeningMode: z.enum(["local", "all"]).default("local"),
26
26
  logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
27
+ sessionStorageMode: z.enum(["project", "global"]).default("project"),
27
28
  // OS notifications
28
29
  osNotificationsEnabled: z.boolean().default(false),
29
30
  osNotificationsAllowWhenVisible: z.boolean().default(false),
@@ -16,6 +16,7 @@ export class EventBus extends EventEmitter {
16
16
  onEvent(listener) {
17
17
  const handler = (event) => listener(event);
18
18
  this.on("workspace.created", handler);
19
+ this.on("workspace.update", handler);
19
20
  this.on("workspace.started", handler);
20
21
  this.on("workspace.error", handler);
21
22
  this.on("workspace.stopped", handler);
@@ -29,6 +30,7 @@ export class EventBus extends EventEmitter {
29
30
  this.on("instance.eventStatus", handler);
30
31
  return () => {
31
32
  this.off("workspace.created", handler);
33
+ this.off("workspace.update", handler);
32
34
  this.off("workspace.started", handler);
33
35
  this.off("workspace.error", handler);
34
36
  this.off("workspace.stopped", handler);
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ import { ClientConnectionManager } from "./clients/connection-manager";
29
29
  import { PluginChannelManager } from "./plugins/channel";
30
30
  import { VoiceModeManager } from "./plugins/voice-mode";
31
31
  import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths";
32
+ import { isBinaryAvailable, triggerBinaryDownload } from "./opencode-paths";
32
33
  const require = createRequire(import.meta.url);
33
34
  const packageJson = { version: readServerPackageVersion(import.meta.url) };
34
35
  const __filename = fileURLToPath(import.meta.url);
@@ -390,6 +391,15 @@ async function main() {
390
391
  else {
391
392
  serverMeta.addresses = [];
392
393
  }
394
+ if (!isBinaryAvailable()) {
395
+ logger.info("OpenCode binary not found, triggering auto-download in background");
396
+ triggerBinaryDownload(logger)
397
+ .then(() => logger.info("OpenCode auto-download completed"))
398
+ .catch((err) => logger.error({ err }, "OpenCode auto-download failed"));
399
+ }
400
+ else {
401
+ logger.info("OpenCode binary found, skipping auto-download");
402
+ }
393
403
  console.log(`Local Connection URL : ${serverMeta.localUrl}`);
394
404
  if (serverMeta.remoteUrl) {
395
405
  console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`);
@@ -461,6 +471,26 @@ async function main() {
461
471
  }
462
472
  main().catch((error) => {
463
473
  const logger = createLogger({ component: "app" });
474
+ const message = error?.message ?? "";
475
+ if (message.includes("No server password configured")) {
476
+ const friendlyMsg = `\
477
+ [ERROR] [auth] EmbeddedCowork cannot start: no server password configured.
478
+
479
+ To fix this, choose one of the following options:
480
+
481
+ 1. Set a password via the command line:
482
+ npx @vividcodeai/embeddedcowork --password <your-password>
483
+
484
+ 2. Set the EMBEDDEDCOWORK_SERVER_PASSWORD environment variable
485
+
486
+ 3. Create an auth.json configuration file (see path in the error above)
487
+
488
+ 4. Use --dangerously-skip-auth to disable authentication (not recommended for production)
489
+ `;
490
+ console.error(friendlyMsg);
491
+ process.exit(1);
492
+ return;
493
+ }
464
494
  logger.error({ err: error }, "CLI server crashed");
465
495
  process.exit(1);
466
496
  });
@@ -0,0 +1,295 @@
1
+ import { createWriteStream, existsSync, mkdirSync, chmodSync, readFileSync, createReadStream, renameSync } from "fs";
2
+ import { stat, mkdir } from "fs/promises";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { spawnSync } from "child_process";
6
+ import yauzl from "yauzl";
7
+ import tar from "tar";
8
+ import { BIN_DIR, BINARY_NAME } from "./opencode-paths";
9
+ const GITHUB_API = "https://api.github.com/repos/anomalyco/opencode/releases/latest";
10
+ const GITHUB_DL = "https://github.com/anomalyco/opencode/releases/latest/download";
11
+ export class OpencodeDownloader {
12
+ constructor(logger) {
13
+ this.logger = logger;
14
+ }
15
+ getDownloadTarget() {
16
+ const platform = process.platform;
17
+ const arch = process.arch;
18
+ let osName;
19
+ switch (platform) {
20
+ case "win32":
21
+ osName = "windows";
22
+ break;
23
+ case "darwin":
24
+ osName = "darwin";
25
+ break;
26
+ case "linux":
27
+ osName = "linux";
28
+ break;
29
+ default:
30
+ throw new Error(`Unsupported platform: ${platform}`);
31
+ }
32
+ let archName;
33
+ switch (arch) {
34
+ case "x64":
35
+ archName = "x64";
36
+ break;
37
+ case "arm64":
38
+ archName = "arm64";
39
+ break;
40
+ default:
41
+ throw new Error(`Unsupported architecture: ${arch}`);
42
+ }
43
+ let target = `${osName}-${archName}`;
44
+ if (arch === "x64" && !this.hasAvx2()) {
45
+ target += "-baseline";
46
+ }
47
+ if (platform === "linux" && this.isMusl()) {
48
+ target += "-musl";
49
+ }
50
+ const archiveExt = platform === "linux" ? ".tar.gz" : ".zip";
51
+ return { filename: `opencode-${target}${archiveExt}`, archiveExt };
52
+ }
53
+ hasAvx2() {
54
+ if (process.platform === "win32") {
55
+ try {
56
+ const ps = `(Add-Type -MemberDefinition "[DllImport(\"kernel32.dll\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)`;
57
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], { encoding: "utf8", timeout: 5000 });
58
+ const out = result.stdout?.trim()?.toLowerCase();
59
+ return out === "true" || out === "1";
60
+ }
61
+ catch {
62
+ return true;
63
+ }
64
+ }
65
+ if (process.platform === "darwin") {
66
+ try {
67
+ const result = spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { encoding: "utf8" });
68
+ return result.status === 0 && result.stdout.trim() === "1";
69
+ }
70
+ catch {
71
+ return true;
72
+ }
73
+ }
74
+ if (process.platform === "linux") {
75
+ try {
76
+ const cpuinfo = readFileSync("/proc/cpuinfo", "utf8");
77
+ return /avx2/i.test(cpuinfo);
78
+ }
79
+ catch {
80
+ return true;
81
+ }
82
+ }
83
+ return true;
84
+ }
85
+ isMusl() {
86
+ try {
87
+ const result = spawnSync("ldd", ["--version"], { encoding: "utf8" });
88
+ return result.stderr?.toLowerCase().includes("musl") ?? false;
89
+ }
90
+ catch {
91
+ return existsSync("/etc/alpine-release");
92
+ }
93
+ }
94
+ async getLatestVersion() {
95
+ const now = Date.now();
96
+ if (OpencodeDownloader.versionCache && now < OpencodeDownloader.versionCache.expiry) {
97
+ return OpencodeDownloader.versionCache.version;
98
+ }
99
+ const res = await fetch(GITHUB_API, {
100
+ headers: { Accept: "application/json", "User-Agent": "embeddedcowork" },
101
+ signal: AbortSignal.timeout(10000),
102
+ });
103
+ if (!res.ok)
104
+ throw new Error(`Failed to fetch latest version: HTTP ${res.status}`);
105
+ const data = (await res.json());
106
+ const version = data.tag_name.replace(/^v/, "");
107
+ OpencodeDownloader.versionCache = { version, expiry: now + 30000 };
108
+ return version;
109
+ }
110
+ async downloadWithRetry(url, dest, progressCb, retries = 5) {
111
+ for (let attempt = 1; attempt <= retries; attempt++) {
112
+ let writer = null;
113
+ try {
114
+ const res = await fetch(url);
115
+ if (!res.ok)
116
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
117
+ const total = Number(res.headers.get("content-length") ?? 0);
118
+ let current = 0;
119
+ const reader = res.body?.getReader();
120
+ if (!reader)
121
+ throw new Error("Response body is not readable");
122
+ writer = createWriteStream(dest);
123
+ while (true) {
124
+ const { done, value } = await reader.read();
125
+ if (done)
126
+ break;
127
+ current += value.length;
128
+ progressCb?.(current, total);
129
+ writer.write(value);
130
+ }
131
+ await new Promise((resolve, reject) => {
132
+ writer.end((err) => (err ? reject(err) : resolve()));
133
+ });
134
+ return;
135
+ }
136
+ catch (err) {
137
+ if (writer) {
138
+ writer.destroy();
139
+ writer = null;
140
+ }
141
+ try {
142
+ import("fs/promises").then((fs) => fs.unlink(dest)).catch(() => { });
143
+ }
144
+ catch { }
145
+ if (attempt === retries)
146
+ throw err;
147
+ const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
148
+ this.logger?.warn({ attempt, retries, err }, `Download attempt ${attempt} failed, retrying in ${delay}ms`);
149
+ await new Promise((r) => setTimeout(r, delay));
150
+ }
151
+ }
152
+ }
153
+ async extractZip(zipPath, outDir) {
154
+ return new Promise((resolve, reject) => {
155
+ yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
156
+ if (err || !zipfile)
157
+ return reject(err ?? new Error("Failed to open zip"));
158
+ zipfile.readEntry();
159
+ zipfile.on("entry", (entry) => {
160
+ if (entry.fileName === BINARY_NAME || entry.fileName.endsWith(`/${BINARY_NAME}`)) {
161
+ zipfile.openReadStream(entry, (err2, readStream) => {
162
+ if (err2 || !readStream)
163
+ return reject(err2 ?? new Error("Failed to open read stream"));
164
+ const tmpPath = path.join(outDir, BINARY_NAME + ".tmp");
165
+ const destPath = path.join(outDir, BINARY_NAME);
166
+ const writer = createWriteStream(tmpPath);
167
+ readStream.pipe(writer);
168
+ writer.on("finish", () => {
169
+ chmodSync(tmpPath, 0o755);
170
+ renameSync(tmpPath, destPath);
171
+ resolve();
172
+ });
173
+ writer.on("error", (e) => {
174
+ try {
175
+ import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => { });
176
+ }
177
+ catch { }
178
+ reject(e);
179
+ });
180
+ });
181
+ }
182
+ else {
183
+ zipfile.readEntry();
184
+ }
185
+ });
186
+ zipfile.on("error", reject);
187
+ zipfile.on("end", () => reject(new Error("Binary not found in archive")));
188
+ });
189
+ });
190
+ }
191
+ async extractTarGz(tarPath, outDir) {
192
+ const extractPath = path.join(outDir, "extracted");
193
+ await mkdir(extractPath, { recursive: true });
194
+ await tar.extract({
195
+ file: tarPath,
196
+ cwd: extractPath,
197
+ filter: (filePath) => filePath === BINARY_NAME || filePath.endsWith(`/${BINARY_NAME}`),
198
+ });
199
+ const binarySrc = path.join(extractPath, BINARY_NAME);
200
+ const tmpPath = path.join(outDir, BINARY_NAME + ".tmp");
201
+ const binaryDest = path.join(outDir, BINARY_NAME);
202
+ await new Promise((resolve, reject) => {
203
+ const src = createReadStream(binarySrc);
204
+ const dst = createWriteStream(tmpPath);
205
+ src.pipe(dst);
206
+ dst.on("finish", () => {
207
+ chmodSync(tmpPath, 0o755);
208
+ renameSync(tmpPath, binaryDest);
209
+ resolve();
210
+ });
211
+ dst.on("error", (e) => {
212
+ try {
213
+ import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => { });
214
+ }
215
+ catch { }
216
+ reject(e);
217
+ });
218
+ src.on("error", (e) => {
219
+ try {
220
+ import("fs/promises").then((fs) => fs.unlink(tmpPath)).catch(() => { });
221
+ }
222
+ catch { }
223
+ reject(e);
224
+ });
225
+ });
226
+ }
227
+ getInstalledPath() {
228
+ return path.join(BIN_DIR, BINARY_NAME);
229
+ }
230
+ async verifyBinary(binaryPath) {
231
+ if (!existsSync(binaryPath))
232
+ return null;
233
+ try {
234
+ const result = spawnSync(binaryPath, ["--version"], { encoding: "utf8", timeout: 5000 });
235
+ if (result.status === 0) {
236
+ return (result.stdout ?? result.stderr ?? "").trim();
237
+ }
238
+ }
239
+ catch { }
240
+ this.logger?.warn({ path: binaryPath }, "Binary exists but --version failed, proceeding anyway");
241
+ return binaryPath;
242
+ }
243
+ async ensureDownloaded(statusCb) {
244
+ const target = this.getDownloadTarget();
245
+ const binaryPath = this.getInstalledPath();
246
+ const existingVersion = await this.verifyBinary(binaryPath);
247
+ try {
248
+ const latestVersion = await this.getLatestVersion();
249
+ if (existingVersion && existingVersion.includes(latestVersion)) {
250
+ this.logger?.info({ version: latestVersion, path: binaryPath }, "OpenCode binary is up to date");
251
+ return binaryPath;
252
+ }
253
+ }
254
+ catch (err) {
255
+ if (existingVersion) {
256
+ this.logger?.warn({ err }, "Failed to check latest version, using existing binary");
257
+ return binaryPath;
258
+ }
259
+ }
260
+ mkdirSync(BIN_DIR, { recursive: true });
261
+ const tmpDir = path.join(os.tmpdir(), "embeddedcowork-dl");
262
+ mkdirSync(tmpDir, { recursive: true });
263
+ const archivePath = path.join(tmpDir, target.filename);
264
+ const downloadUrl = `${GITHUB_DL}/${target.filename}`;
265
+ try {
266
+ statusCb?.({ type: "downloading", progress: { current: 0, total: 0 } });
267
+ await this.downloadWithRetry(downloadUrl, archivePath, (current, total) => {
268
+ statusCb?.({ type: "downloading", progress: { current, total } });
269
+ });
270
+ statusCb?.({ type: "extracting" });
271
+ if (target.archiveExt === ".zip") {
272
+ await this.extractZip(archivePath, BIN_DIR);
273
+ }
274
+ else {
275
+ await this.extractTarGz(archivePath, BIN_DIR);
276
+ }
277
+ statusCb?.({ type: "verifying" });
278
+ const version = await this.verifyBinary(binaryPath);
279
+ if (!version) {
280
+ throw new Error("Downloaded binary failed verification");
281
+ }
282
+ this.logger?.info({ version, path: binaryPath }, "OpenCode binary downloaded and verified");
283
+ statusCb?.({ type: "completed", binaryPath });
284
+ return binaryPath;
285
+ }
286
+ finally {
287
+ try {
288
+ await stat(archivePath);
289
+ await import("fs/promises").then((fs) => fs.unlink(archivePath));
290
+ }
291
+ catch { }
292
+ }
293
+ }
294
+ }
295
+ OpencodeDownloader.versionCache = null;
@@ -0,0 +1,167 @@
1
+ import { spawnSync } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { OpencodeDownloader } from "./opencode-downloader";
6
+ // ── Path constants ──────────────────────────────────────────
7
+ export const BIN_DIR = path.join(os.homedir(), ".embeddedcowork", "bin");
8
+ export const BINARY_NAME = process.platform === "win32" ? "opencode.exe" : "opencode";
9
+ export const INSTALLED_BINARY_PATH = path.join(BIN_DIR, BINARY_NAME);
10
+ // ── Shell helpers ───────────────────────────────────────────
11
+ function defaultShellPath() {
12
+ const configured = process.env.SHELL?.trim();
13
+ if (configured)
14
+ return configured;
15
+ return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
16
+ }
17
+ function shellEscape(input) {
18
+ if (!input)
19
+ return "''";
20
+ return `'${input.replace(/'/g, `'\\''`)}'`;
21
+ }
22
+ function wrapCommandForShell(command, shellPath) {
23
+ const shellName = path.basename(shellPath).toLowerCase();
24
+ if (shellName.includes("bash")) {
25
+ return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`;
26
+ }
27
+ if (shellName.includes("zsh")) {
28
+ return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`;
29
+ }
30
+ return command;
31
+ }
32
+ function buildShellArgs(shellPath, command) {
33
+ const shellName = path.basename(shellPath).toLowerCase();
34
+ if (shellName.includes("zsh"))
35
+ return ["-l", "-i", "-c", command];
36
+ return ["-l", "-c", command];
37
+ }
38
+ // ── Binary lookup ───────────────────────────────────────────
39
+ export function resolveBinaryPathFromUserShell(identifier) {
40
+ if (process.platform === "win32")
41
+ return null;
42
+ const shellPath = defaultShellPath();
43
+ const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath);
44
+ const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
45
+ encoding: "utf8",
46
+ env: {
47
+ ...process.env,
48
+ npm_config_prefix: undefined,
49
+ NPM_CONFIG_PREFIX: undefined,
50
+ },
51
+ });
52
+ if (result.status !== 0)
53
+ return null;
54
+ const resolved = String(result.stdout ?? "")
55
+ .split(/\r?\n/)
56
+ .map((line) => line.trim())
57
+ .find((line) => line.length > 0);
58
+ return resolved ?? null;
59
+ }
60
+ /**
61
+ * Resolve an opencode binary identifier to an absolute path.
62
+ * Order: absolute/relative → which/where → shell command -v → installed path → raw identifier
63
+ */
64
+ export function resolveBinary(identifier) {
65
+ if (!identifier)
66
+ return identifier;
67
+ if (path.isAbsolute(identifier) || identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")) {
68
+ return identifier;
69
+ }
70
+ const locator = process.platform === "win32" ? "where" : "which";
71
+ try {
72
+ const result = spawnSync(locator, [identifier], { encoding: "utf8" });
73
+ if (result.status === 0 && result.stdout) {
74
+ const candidates = result.stdout
75
+ .split(/\r?\n/)
76
+ .map((line) => line.trim())
77
+ .filter((line) => line.length > 0)
78
+ .filter((line) => !/^INFO:/i.test(line));
79
+ if (candidates.length > 0) {
80
+ return candidates[0];
81
+ }
82
+ }
83
+ }
84
+ catch { }
85
+ const shellResolved = resolveBinaryPathFromUserShell(identifier);
86
+ if (shellResolved)
87
+ return shellResolved;
88
+ if (existsSync(INSTALLED_BINARY_PATH))
89
+ return INSTALLED_BINARY_PATH;
90
+ return identifier;
91
+ }
92
+ let downloadPhase = "idle";
93
+ let downloadProgress = { current: 0, total: 0 };
94
+ let downloadError;
95
+ function setDownloadPhase(phase) {
96
+ downloadPhase = phase;
97
+ if (phase === "idle" || phase === "completed" || phase === "error") {
98
+ downloadProgress = { current: 0, total: 0 };
99
+ }
100
+ }
101
+ export function getDownloadProgress() {
102
+ return { ...downloadProgress };
103
+ }
104
+ export function getDownloadPhase() {
105
+ return downloadPhase;
106
+ }
107
+ export function getDownloadError() {
108
+ return downloadError;
109
+ }
110
+ /**
111
+ * Check whether opencode is available on this system.
112
+ * Uses the same lookup order as resolveBinary("opencode").
113
+ * Returns additional download progress info when not available.
114
+ */
115
+ export function getOpencodeStatus() {
116
+ const resolved = resolveBinary("opencode");
117
+ const available = resolved !== "opencode" && existsSync(resolved);
118
+ if (available)
119
+ return { available: true };
120
+ return {
121
+ available: false,
122
+ status: downloadPhase,
123
+ progress: downloadPhase === "downloading" ? { ...downloadProgress } : undefined,
124
+ error: downloadError,
125
+ };
126
+ }
127
+ export function isBinaryAvailable() {
128
+ const resolved = resolveBinary("opencode");
129
+ return resolved !== "opencode" && existsSync(resolved);
130
+ }
131
+ // ── Download trigger (global singleton) ─────────────────────
132
+ let downloadPromise = null;
133
+ export function triggerBinaryDownload(logger) {
134
+ if (downloadPromise)
135
+ return downloadPromise;
136
+ setDownloadPhase("downloading");
137
+ downloadError = undefined;
138
+ const downloader = new OpencodeDownloader(logger);
139
+ downloadPromise = downloader
140
+ .ensureDownloaded((status) => {
141
+ if (status.type === "downloading") {
142
+ downloadPhase = "downloading";
143
+ downloadProgress = status.progress;
144
+ }
145
+ else if (status.type === "extracting") {
146
+ setDownloadPhase("extracting");
147
+ }
148
+ else if (status.type === "verifying") {
149
+ setDownloadPhase("verifying");
150
+ }
151
+ else if (status.type === "completed") {
152
+ setDownloadPhase("completed");
153
+ }
154
+ })
155
+ .then(() => {
156
+ logger.info("OpenCode auto-download completed");
157
+ setDownloadPhase("completed");
158
+ downloadPromise = null;
159
+ })
160
+ .catch((err) => {
161
+ const message = err instanceof Error ? err.message : String(err);
162
+ logger.error({ err }, "OpenCode auto-download failed");
163
+ downloadPhase = "error";
164
+ downloadError = message;
165
+ });
166
+ return downloadPromise;
167
+ }
@@ -19,6 +19,7 @@ import { registerEventRoutes } from "./routes/events";
19
19
  import { registerStorageRoutes } from "./routes/storage";
20
20
  import { registerPluginRoutes } from "./routes/plugin";
21
21
  import { registerBackgroundProcessRoutes } from "./routes/background-processes";
22
+ import { registerOpencodeStatusRoutes } from "./routes/opencode-status";
22
23
  import { registerWorktreeRoutes } from "./routes/worktrees";
23
24
  import { registerSpeechRoutes } from "./routes/speech";
24
25
  import { registerRemoteServerRoutes } from "./routes/remote-servers";
@@ -194,6 +195,7 @@ export function createHttpServer(deps) {
194
195
  });
195
196
  registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
196
197
  registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger });
198
+ registerOpencodeStatusRoutes(app, { settings: deps.settings, logger: apiLogger });
197
199
  registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser });
198
200
  registerMetaRoutes(app, { serverMeta: deps.serverMeta });
199
201
  registerEventRoutes(app, {
@@ -0,0 +1,10 @@
1
+ import { getOpencodeStatus, triggerBinaryDownload } from "../../opencode-paths";
2
+ export function registerOpencodeStatusRoutes(app, deps) {
3
+ app.get("/api/opencode/status", async (_request, reply) => {
4
+ const status = getOpencodeStatus();
5
+ if (!status.available) {
6
+ void triggerBinaryDownload(deps.logger);
7
+ }
8
+ return reply.send(status);
9
+ });
10
+ }