From 4aeb81eeeb1e5e912257dae58c61537e73eb6ea4 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 20 Apr 2026 04:24:04 +0100 Subject: [PATCH] fix: relaunch setup tui in a fresh process --- src/tui/tui-launch.ts | 70 +++++++++++++++++++++++++++++++ src/wizard/setup.finalize.test.ts | 10 ++--- src/wizard/setup.finalize.ts | 5 ++- 3 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 src/tui/tui-launch.ts diff --git a/src/tui/tui-launch.ts b/src/tui/tui-launch.ts new file mode 100644 index 00000000000..16ba6b8caa9 --- /dev/null +++ b/src/tui/tui-launch.ts @@ -0,0 +1,70 @@ +import { spawn } from "node:child_process"; +import { formatErrorMessage } from "../infra/errors.js"; +import { attachChildProcessBridge } from "../process/child-process-bridge.js"; +import type { TuiOptions } from "./tui.js"; + +function appendOption(args: string[], flag: string, value: string | number | undefined): void { + if (value === undefined) { + return; + } + args.push(flag, String(value)); +} + +function buildTuiCliArgs(opts: TuiOptions): string[] { + const entry = process.argv[1]?.trim(); + if (!entry) { + throw new Error("unable to relaunch TUI: current CLI entry path is unavailable"); + } + + const args = [...process.execArgv, entry, "tui"]; + appendOption(args, "--url", opts.url); + appendOption(args, "--token", opts.token); + appendOption(args, "--password", opts.password); + appendOption(args, "--session", opts.session); + appendOption(args, "--thinking", opts.thinking); + appendOption(args, "--message", opts.message); + appendOption(args, "--timeout-ms", opts.timeoutMs); + appendOption(args, "--history-limit", opts.historyLimit); + if (opts.deliver) { + args.push("--deliver"); + } + return args; +} + +export async function launchTuiCli(opts: TuiOptions): Promise { + const args = buildTuiCliArgs(opts); + const stdinWasPaused = + typeof process.stdin.isPaused === "function" ? process.stdin.isPaused() : false; + + process.stdin.pause(); + + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + stdio: "inherit", + env: process.env, + }); + const { detach } = attachChildProcessBridge(child); + + child.once("error", (error) => { + detach(); + reject(new Error(`failed to launch TUI: ${formatErrorMessage(error)}`)); + }); + + child.once("exit", (code, signal) => { + detach(); + if (signal) { + reject(new Error(`TUI exited from signal ${signal}`)); + return; + } + if ((code ?? 0) !== 0) { + reject(new Error(`TUI exited with code ${code ?? 1}`)); + return; + } + resolve(); + }); + }).finally(() => { + if (!stdinWasPaused) { + process.stdin.resume(); + } + }); +} diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index dbb0f08db01..b1dd617d510 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; -const runTui = vi.hoisted(() => vi.fn(async () => {})); +const launchTuiCli = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })), ); @@ -137,8 +137,8 @@ vi.mock("../terminal/restore.js", () => ({ restoreTerminalState: vi.fn(), })); -vi.mock("../tui/tui.js", () => ({ - runTui, +vi.mock("../tui/tui-launch.js", () => ({ + launchTuiCli, })); vi.mock("./setup.secret-input.js", () => ({ @@ -236,7 +236,7 @@ function createAdvancedFinalizeArgs(params: AdvancedFinalizeArgs = {}) { describe("finalizeSetupWizard", () => { beforeEach(() => { - runTui.mockClear(); + launchTuiCli.mockClear(); probeGatewayReachable.mockClear(); waitForGatewayReachable.mockReset(); waitForGatewayReachable.mockResolvedValue({ ok: true }); @@ -337,7 +337,7 @@ describe("finalizeSetupWizard", () => { password: "resolved-gateway-password", // pragma: allowlist secret }), ); - expect(runTui).toHaveBeenCalledWith( + expect(launchTuiCli).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789", password: "resolved-gateway-password", // pragma: allowlist secret diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index fc1fdc61509..e7461a877e4 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -30,7 +30,7 @@ import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.js"; import { restoreTerminalState } from "../terminal/restore.js"; -import { runTui } from "../tui/tui.js"; +import { launchTuiCli } from "../tui/tui-launch.js"; import { resolveUserPath } from "../utils.js"; import { listConfiguredWebSearchProviders } from "../web-search/runtime.js"; import type { WizardPrompter } from "./prompts.js"; @@ -423,7 +423,7 @@ export async function finalizeSetupWizard( if (hatchChoice === "tui") { restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: true }); - await runTui({ + await launchTuiCli({ url: links.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined, password: settings.authMode === "password" ? resolvedGatewayPassword : "", @@ -431,6 +431,7 @@ export async function finalizeSetupWizard( deliver: false, message: hasBootstrap ? "Wake up, my friend!" : undefined, }); + restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true }); launchedTui = true; } else if (hatchChoice === "web") { const browserSupport = await detectBrowserOpenSupport();