diff --git a/src/service.ts b/src/service.ts
index 09988e54d..2ffdc56a0 100644
--- a/src/service.ts
+++ b/src/service.ts
@@ -45,6 +45,10 @@ function windowsServiceScriptPath(): string {
return join(getConfigDir(), "opencodex-service.cmd");
}
+function windowsServiceVbsPath(): string {
+ return join(getConfigDir(), "opencodex-service.vbs");
+}
+
function windowsTaskXmlPath(): string {
return join(getConfigDir(), "opencodex-service-task.xml");
}
@@ -295,7 +299,7 @@ export function buildWindowsServiceScript(entry = cliEntry()): string {
const lines = [
"@echo off",
"setlocal",
- // The wrapper runs in its own hidden console, so switching that console to UTF-8 is
+ // The wrapper is launched via a hidden WScript helper, so switching that console to UTF-8 is
// safe (no leak into user shells) and lets cmd parse any UTF-8 remnants correctly.
"chcp 65001 >nul",
windowsBatchSet("OCX_SERVICE", "1"),
@@ -335,8 +339,19 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
}
-export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string {
- const escapedScript = taskXmlString(script);
+/**
+ * Hidden launcher for the service `.cmd`. Task Scheduler Exec of a console-subsystem
+ * batch paints a visible window; WScript Run with window style 0 does not.
+ * WaitOnReturn must be True so the scheduled task stays Running while the wrapper
+ * (and proxy) are alive — False returns immediately and Task Scheduler flips to Ready.
+ */
+export function buildWindowsServiceVbs(script = windowsServiceScriptPath()): string {
+ const escaped = script.replace(/"/g, '""');
+ return `CreateObject("WScript.Shell").Run """${escaped}""", 0, True\r\n`;
+}
+
+export function buildWindowsTaskXml(vbs = windowsServiceVbsPath()): string {
+ const escapedArgs = taskXmlString(`//B //Nologo "${vbs}"`);
return `
@@ -362,7 +377,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string
false
true
true
- false
+ true
PT0S
7
@@ -372,7 +387,8 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string
- ${escapedScript}
+ wscript.exe
+ ${escapedArgs}
@@ -425,8 +441,10 @@ function installWindows(): void {
// script mid-rewrite runs a torn batch file, and its open handle can fail the write.
try { stopWindows(); } catch { /* not running */ }
const script = windowsServiceScriptPath();
+ const vbs = windowsServiceVbsPath();
writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
- writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
+ writeServiceAssetWithRetry(vbs, buildWindowsServiceVbs(script), "utf8");
+ writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(vbs)}`, "utf16le");
schtasks(buildWindowsSchtasksCreateArgs(script));
schtasks(["/run", "/tn", TASK]);
writeServiceInstallState();
@@ -437,6 +455,7 @@ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]
function uninstallWindows(): void {
try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
+ if (existsSync(windowsServiceVbsPath())) unlinkSync(windowsServiceVbsPath());
if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
}
diff --git a/src/update/index.ts b/src/update/index.ts
index fa2da0c03..78fafa0a8 100644
--- a/src/update/index.ts
+++ b/src/update/index.ts
@@ -58,6 +58,23 @@ function npmSpawnTarget(bin: string): { bin: string; shell: boolean } {
return { bin: "npm.cmd", shell: true };
}
+/**
+ * GUI update worker sets OCX_SERVICE=1 and has stdio ignored — inheriting that for
+ * `npm.cmd` (shell:true) opens stacked visible consoles on Windows. Pipe instead.
+ */
+function updateChildStdio(): "inherit" | "pipe" {
+ if (process.env.OCX_SERVICE === "1") return "pipe";
+ if (typeof process.stdout.isTTY === "boolean" && !process.stdout.isTTY) return "pipe";
+ return "inherit";
+}
+
+function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null }): void {
+ const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
+ if (stdout) console.log(stdout.length > 4000 ? `${label}${stdout.slice(-4000)}` : stdout);
+ if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr);
+}
+
/** Latest published version from the registry (best-effort; null if npm isn't available). */
export function latestVersion(tag: string): string | null {
const npm = npmSpawnTarget("npm");
@@ -160,7 +177,13 @@ export async function runUpdate(): Promise {
// Full `ocx stop` semantics (drain, service stop, restore).
if (serviceWasInstalled || readPid() || readRuntimePort()) {
console.log("⏹ Stopping the running proxy before updating...");
- const stop = spawnSync(process.execPath, [process.argv[1], "stop"], { stdio: "inherit", windowsHide: true });
+ const stopStdio = updateChildStdio();
+ const stop = spawnSync(process.execPath, [process.argv[1], "stop"], {
+ stdio: stopStdio,
+ encoding: stopStdio === "pipe" ? "utf8" : undefined,
+ windowsHide: true,
+ });
+ if (stopStdio === "pipe") logSpawnOutput("", stop);
if (stop.status !== 0 || readPid() || readRuntimePort()) {
console.error("⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
process.exit(1);
@@ -178,7 +201,15 @@ export async function runUpdate(): Promise {
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
const target = npmSpawnTarget(bin);
- const r = spawnSync(target.bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true, shell: target.shell });
+ const installStdio = updateChildStdio();
+ const r = spawnSync(target.bin, cmdArgs, {
+ stdio: installStdio,
+ encoding: installStdio === "pipe" ? "utf8" : undefined,
+ timeout: 180000,
+ windowsHide: true,
+ shell: target.shell,
+ });
+ if (installStdio === "pipe") logSpawnOutput("", r);
if (r.status === 0) {
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
// Re-bake the bundled Bun path into the Codex autostart shim on every
@@ -197,7 +228,13 @@ export async function runUpdate(): Promise {
// launchd/schtasks/systemd user isn't left with the background proxy down.
if (serviceWasInstalled) {
console.log("🔁 Reinstalling the background service with the updated files...");
- const svc = spawnSync(process.execPath, [process.argv[1], "service", "install"], { stdio: "inherit", windowsHide: true });
+ const svcStdio = updateChildStdio();
+ const svc = spawnSync(process.execPath, [process.argv[1], "service", "install"], {
+ stdio: svcStdio,
+ encoding: svcStdio === "pipe" ? "utf8" : undefined,
+ windowsHide: true,
+ });
+ if (svcStdio === "pipe") logSpawnOutput("", svc);
if (svc.status !== 0) console.warn("⚠️ Service refresh failed — run 'ocx service install' manually.");
} else {
console.log("Restart the proxy: ocx start");
diff --git a/tests/service.test.ts b/tests/service.test.ts
index 97e97f6c9..68715a752 100644
--- a/tests/service.test.ts
+++ b/tests/service.test.ts
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { saveConfig } from "../src/config";
import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths";
-import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, normalizeServiceSubcommand, serviceLogPath, serviceStatusSummary } from "../src/service";
+import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsServiceVbs, buildWindowsTaskXml, normalizeServiceSubcommand, serviceLogPath, serviceStatusSummary } from "../src/service";
import { serviceApiTokenFilePath } from "../src/lib/service-secrets";
import type { OcxConfig } from "../src/types";
@@ -184,8 +184,8 @@ describe("Windows service task", () => {
});
test("builds service-like Task Scheduler XML settings", () => {
- const script = "C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd";
- const xml = buildWindowsTaskXml(script);
+ const vbs = "C:\\Users\\a&b\\.opencodex\\opencodex-service.vbs";
+ const xml = buildWindowsTaskXml(vbs);
expect(xml).toContain('');
expect(xml).toContain("");
@@ -197,13 +197,24 @@ describe("Windows service task", () => {
expect(xml).toContain("");
expect(xml).toContain("PT1M");
expect(xml).toContain("3");
- expect(xml).toContain("C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd");
+ expect(xml).toContain("true");
+ expect(xml).toContain("wscript.exe");
+ expect(xml).toContain('//B //Nologo "C:\\Users\\a&b\\.opencodex\\opencodex-service.vbs"');
+ expect(xml).not.toContain("opencodex-service.cmd");
+ });
+
+ test("builds a hidden WScript launcher for the service batch", () => {
+ const script = "C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd";
+ expect(buildWindowsServiceVbs(script)).toBe(
+ 'CreateObject("WScript.Shell").Run """C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd""", 0, True\r\n',
+ );
});
test("writes Task Scheduler XML with a UTF-16 BOM for schtasks", async () => {
const service = await Bun.file(new URL("../src/service.ts", import.meta.url)).text();
- expect(service).toContain('writeServiceAssetWithRetry(windowsTaskXmlPath(), `\\uFEFF${buildWindowsTaskXml(script)}`, "utf16le")');
+ expect(service).toContain('writeServiceAssetWithRetry(windowsTaskXmlPath(), `\\uFEFF${buildWindowsTaskXml(vbs)}`, "utf16le")');
+ expect(service).toContain("writeServiceAssetWithRetry(vbs, buildWindowsServiceVbs(script), \"utf8\")");
});
test("escapes environment values that would break out of set quotes", () => {
@@ -385,7 +396,9 @@ describe("service lifecycle cleanup ordering", () => {
const uninstallWindows = service.slice(service.indexOf("function uninstallWindows()"), service.indexOf("function serviceDiagnosticsSummary()"));
expect(uninstallWindows).toContain("windowsServiceScriptPath()");
+ expect(uninstallWindows).toContain("windowsServiceVbsPath()");
expect(uninstallWindows).toContain("windowsTaskXmlPath()");
+ expect(uninstallWindows).toContain("unlinkSync(windowsServiceVbsPath())");
expect(uninstallWindows).toContain("unlinkSync(windowsTaskXmlPath())");
});
diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts
index 081fb225a..d4794e117 100644
--- a/tests/update-stop-first.test.ts
+++ b/tests/update-stop-first.test.ts
@@ -65,6 +65,14 @@ describe("update stops the running proxy before replacing files", () => {
expect(launcherSource).toContain("if (serviceWasInstalled || hasRuntimeState)");
expect(launcherSource).toContain("stopRes.status !== 0 || stillHasRuntimeState");
});
+
+ test("GUI worker update children use pipe stdio so Windows npm.cmd does not open consoles", () => {
+ expect(updateSource).toContain("function updateChildStdio()");
+ expect(updateSource).toContain('process.env.OCX_SERVICE === "1"');
+ expect(updateSource).toContain('return "pipe"');
+ expect(updateSource).toContain("stdio: installStdio");
+ expect(updateSource).toContain("windowsHide: true");
+ });
});
describe("/healthz identity fields", () => {