diff --git a/CHANGELOG.md b/CHANGELOG.md index 3237424dacc..d5ea92bcc5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/dev: run `pnpm gateway:watch` through a named tmux session by default, with `gateway:watch:raw` and `OPENCLAW_GATEWAY_WATCH_TMUX=0` for foreground mode, so repeated starts respawn an inspectable watcher without trapping the invoking agent shell. Thanks @vincentkoc. - Plugin SDK: mark remaining legacy alias exports and diffs tool/config aliases with deprecation metadata, and add a guard so future legacy alias comments require `@deprecated` tags. Thanks @vincentkoc. - CLI/QR/dependencies: internalize small terminal progress and QR wrapper helpers while keeping the real QR encoder dependency direct, reducing the default runtime dependency graph without changing QR output behavior. Thanks @vincentkoc. - Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay. diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 08f95ba5c53..9d2766d2898 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -216,12 +216,42 @@ For fast iteration, run the gateway under the file watcher: pnpm gateway:watch ``` -This maps to: +By default, this starts or restarts a tmux session named +`openclaw-gateway-watch-main` (or a profile/port-specific variant such as +`openclaw-gateway-watch-dev-19001`) and auto-attaches from interactive terminals. +Non-interactive shells, CI, and agent exec calls stay detached and print attach +instructions instead. Attach manually when needed: + +```bash +tmux attach -t openclaw-gateway-watch-main +``` + +The tmux pane runs the raw watcher: ```bash node scripts/watch-node.mjs gateway --force ``` +Use foreground mode when tmux is not wanted: + +```bash +pnpm gateway:watch:raw +# or +OPENCLAW_GATEWAY_WATCH_TMUX=0 pnpm gateway:watch +``` + +Disable auto-attach while keeping tmux management: + +```bash +OPENCLAW_GATEWAY_WATCH_ATTACH=0 pnpm gateway:watch +``` + +The tmux wrapper carries common non-secret runtime selectors such as +`OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR`, +`OPENCLAW_GATEWAY_PORT`, and `OPENCLAW_SKIP_CHANNELS` into the pane. Put +provider credentials in your normal profile/config, or use raw foreground mode +for one-off ephemeral secrets. + The watcher restarts on build-relevant files under `src/`, extension source files, extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`, `package.json`, and `tsdown.config.ts`. Extension metadata changes restart the @@ -229,8 +259,9 @@ gateway without forcing a `tsdown` rebuild; source and config changes still rebuild `dist` first. Add any gateway CLI flags after `gateway:watch` and they will be passed through on -each restart. Re-running the same watch command for the same repo/flag set now -replaces the older watcher instead of leaving duplicate watcher parents behind. +each restart. Re-running the same watch command respawns the named tmux pane, and +the raw watcher still keeps its single-watcher lock so duplicate watcher parents +are replaced instead of piling up. ## Dev profile + dev gateway (--dev) diff --git a/docs/start/setup.md b/docs/start/setup.md index 227b5f16b56..fa70dbb18d7 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -96,8 +96,12 @@ pnpm openclaw setup pnpm gateway:watch ``` -`gateway:watch` runs the gateway in watch mode and reloads on relevant source, -config, and bundled-plugin metadata changes. +`gateway:watch` starts or restarts the Gateway watch process in a named tmux +session and auto-attaches from interactive terminals. Non-interactive shells stay +detached and print `tmux attach -t openclaw-gateway-watch-main`; use +`OPENCLAW_GATEWAY_WATCH_ATTACH=0 pnpm gateway:watch` to keep an interactive run +detached, or `pnpm gateway:watch:raw` for foreground watch mode. The watcher +reloads on relevant source, config, and bundled-plugin metadata changes. `pnpm openclaw setup` is the one-time local config/workspace initialization step for a fresh checkout. `pnpm gateway:watch` does not rebuild `dist/control-ui`, so rerun `pnpm ui:build` after `ui/` changes or use `pnpm ui:dev` while developing the Control UI. diff --git a/package.json b/package.json index cc9fd4ba933..fec2c2d5904 100644 --- a/package.json +++ b/package.json @@ -1334,7 +1334,8 @@ "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/OpenClawKit/Sources", "gateway:dev": "OPENCLAW_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway", "gateway:dev:reset": "OPENCLAW_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset", - "gateway:watch": "node scripts/watch-node.mjs gateway --force", + "gateway:watch": "node scripts/gateway-watch-tmux.mjs gateway --force", + "gateway:watch:raw": "node scripts/watch-node.mjs gateway --force", "gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write", "ghsa:patch": "node scripts/ghsa-patch.mjs", "ios:beta": "bash scripts/ios-beta-release.sh", diff --git a/scripts/gateway-watch-tmux.d.mts b/scripts/gateway-watch-tmux.d.mts new file mode 100644 index 00000000000..b50f8f260c8 --- /dev/null +++ b/scripts/gateway-watch-tmux.d.mts @@ -0,0 +1,35 @@ +export function resolveGatewayWatchTmuxSessionName(params?: { + args?: string[]; + env?: NodeJS.ProcessEnv; +}): string; + +export function buildGatewayWatchTmuxCommand(params?: { + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + nodePath?: string; + sessionName?: string; +}): string; + +export function runGatewayWatchTmuxMain(params?: { + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + nodePath?: string; + sessionName?: string; + spawnSync?: ( + cmd: string, + args: string[], + options: unknown, + ) => { + error?: NodeJS.ErrnoException; + signal?: NodeJS.Signals | null; + status?: number | null; + stderr?: string; + stdout?: string; + }; + stderr?: { write: (message: string) => void }; + stdinIsTTY?: boolean; + stdout?: { write: (message: string) => void }; + stdoutIsTTY?: boolean; +}): number; diff --git a/scripts/gateway-watch-tmux.mjs b/scripts/gateway-watch-tmux.mjs new file mode 100644 index 00000000000..24b6cc5703e --- /dev/null +++ b/scripts/gateway-watch-tmux.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const TMUX_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); +const TMUX_ATTACH_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); +const TMUX_ATTACH_FORCE_VALUES = new Set(["1", "true", "yes", "on"]); +const DEFAULT_PROFILE_NAME = "main"; +const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs"; +const TMUX_CHILD_ENV_KEYS = [ + "NODE_OPTIONS", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_GATEWAY_PORT", + "OPENCLAW_HOME", + "OPENCLAW_PROFILE", + "OPENCLAW_SKIP_CHANNELS", + "OPENCLAW_STATE_DIR", +]; + +const sanitizeSessionPart = (value) => { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || DEFAULT_PROFILE_NAME; +}; + +const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`; + +const readArgValue = (args, flag) => { + const prefix = `${flag}=`; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === flag) { + const next = args[index + 1]; + return typeof next === "string" && !next.startsWith("-") ? next : null; + } + if (typeof arg === "string" && arg.startsWith(prefix)) { + return arg.slice(prefix.length); + } + } + return null; +}; + +export const resolveGatewayWatchTmuxSessionName = ({ args = [], env = process.env } = {}) => { + const profile = + env.OPENCLAW_PROFILE || + readArgValue(args, "--profile") || + (args.includes("--dev") ? "dev" : null); + const port = env.OPENCLAW_GATEWAY_PORT || readArgValue(args, "--port"); + const parts = [ + "openclaw", + "gateway", + "watch", + sanitizeSessionPart(profile ?? DEFAULT_PROFILE_NAME), + ]; + if (port && port !== "18789") { + parts.push(sanitizeSessionPart(port)); + } + return parts.join("-"); +}; + +const resolveShell = (env) => env.SHELL || "/bin/sh"; + +export const buildGatewayWatchTmuxCommand = ({ + args = [], + cwd = process.cwd(), + env = process.env, + nodePath = process.execPath, + sessionName, +} = {}) => { + const shell = resolveShell(env); + const childEnv = [ + "env", + `OPENCLAW_GATEWAY_WATCH_TMUX_CHILD=1`, + `OPENCLAW_GATEWAY_WATCH_SESSION=${sessionName}`, + ...TMUX_CHILD_ENV_KEYS.flatMap((key) => + env[key] == null || env[key] === "" ? [] : [`${key}=${env[key]}`], + ), + ]; + const watchCommand = [ + "cd", + shellQuote(cwd), + "&&", + "exec", + ...childEnv.map(shellQuote), + shellQuote(nodePath), + shellQuote(RAW_WATCH_SCRIPT), + ...args.map(shellQuote), + ].join(" "); + return `exec ${shellQuote(shell)} -lc ${shellQuote(watchCommand)}`; +}; + +const runForegroundWatcher = ({ args, cwd, env, nodePath, spawnSyncImpl, stdio = "inherit" }) => { + const result = spawnSyncImpl(nodePath, [RAW_WATCH_SCRIPT, ...args], { + cwd, + env, + stdio, + }); + return result.status ?? (result.signal ? 1 : 0); +}; + +const runTmux = (spawnSyncImpl, args, options = {}) => + spawnSyncImpl("tmux", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + +const log = (stderr, message) => { + stderr.write(`[openclaw] ${message}\n`); +}; + +const getTmuxErrorText = (result) => + result.error?.message || String(result.stderr || "").trim() || "unknown error"; + +const isMissingTmuxTarget = (result) => + /can't find (?:session|window|pane)|no current target/i.test(getTmuxErrorText(result)); + +const shouldAttachTmux = ({ env, stdinIsTTY, stdoutIsTTY }) => { + const raw = String(env.OPENCLAW_GATEWAY_WATCH_ATTACH ?? "").toLowerCase(); + if (TMUX_ATTACH_FORCE_VALUES.has(raw)) { + return true; + } + if (TMUX_ATTACH_DISABLE_VALUES.has(raw)) { + return false; + } + return !env.CI && stdinIsTTY === true && stdoutIsTTY === true; +}; + +const attachTmux = ({ env, sessionName, spawnSyncImpl }) => { + const args = env.TMUX + ? ["switch-client", "-t", sessionName] + : ["attach-session", "-t", sessionName]; + return runTmux(spawnSyncImpl, args, { stdio: "inherit" }); +}; + +export const runGatewayWatchTmuxMain = (params = {}) => { + const deps = { + args: params.args ?? process.argv.slice(2), + cwd: params.cwd ?? process.cwd(), + env: params.env ? { ...params.env } : { ...process.env }, + nodePath: params.nodePath ?? process.execPath, + spawnSync: params.spawnSync ?? spawnSync, + stderr: params.stderr ?? process.stderr, + stdinIsTTY: params.stdinIsTTY ?? process.stdin.isTTY, + stdout: params.stdout ?? process.stdout, + stdoutIsTTY: params.stdoutIsTTY ?? process.stdout.isTTY, + }; + + if (TMUX_DISABLE_VALUES.has(String(deps.env.OPENCLAW_GATEWAY_WATCH_TMUX ?? "").toLowerCase())) { + return runForegroundWatcher({ + args: deps.args, + cwd: deps.cwd, + env: deps.env, + nodePath: deps.nodePath, + spawnSyncImpl: deps.spawnSync, + }); + } + + if (deps.env.OPENCLAW_GATEWAY_WATCH_TMUX_CHILD === "1") { + return runForegroundWatcher({ + args: deps.args, + cwd: deps.cwd, + env: deps.env, + nodePath: deps.nodePath, + spawnSyncImpl: deps.spawnSync, + }); + } + + const sessionName = + params.sessionName ?? resolveGatewayWatchTmuxSessionName({ args: deps.args, env: deps.env }); + const command = buildGatewayWatchTmuxCommand({ + args: deps.args, + cwd: deps.cwd, + env: deps.env, + nodePath: deps.nodePath, + sessionName, + }); + + const hasSession = runTmux(deps.spawnSync, ["has-session", "-t", sessionName]); + if (hasSession.error?.code === "ENOENT") { + log( + deps.stderr, + "tmux is not installed or not on PATH; run `pnpm gateway:watch:raw` for foreground watch mode.", + ); + return 1; + } + if (hasSession.error) { + log(deps.stderr, `failed to query tmux session ${sessionName}: ${hasSession.error.message}`); + return 1; + } + + const startSession = () => + runTmux(deps.spawnSync, ["new-session", "-d", "-s", sessionName, "-c", deps.cwd, command]); + const restartSession = () => + runTmux(deps.spawnSync, ["respawn-pane", "-k", "-t", sessionName, "-c", deps.cwd, command]); + const action = hasSession.status === 0 ? "restarted" : "started"; + let result = hasSession.status === 0 ? restartSession() : startSession(); + if (hasSession.status === 0 && isMissingTmuxTarget(result)) { + runTmux(deps.spawnSync, ["kill-session", "-t", sessionName]); + result = startSession(); + } + if (result.error?.code === "ENOENT") { + log( + deps.stderr, + "tmux is not installed or not on PATH; run `pnpm gateway:watch:raw` for foreground watch mode.", + ); + return 1; + } + if (result.error || result.status !== 0) { + const detail = getTmuxErrorText(result); + log( + deps.stderr, + `failed to ${action === "started" ? "start" : "restart"} tmux session ${sessionName}: ${detail}`, + ); + return result.status || 1; + } + + log(deps.stderr, `gateway:watch ${action} in tmux session ${sessionName}`); + if ( + shouldAttachTmux({ + env: deps.env, + stdinIsTTY: deps.stdinIsTTY, + stdoutIsTTY: deps.stdoutIsTTY, + }) + ) { + const attachResult = attachTmux({ + env: deps.env, + sessionName, + spawnSyncImpl: deps.spawnSync, + }); + if (attachResult.error || attachResult.status !== 0) { + const detail = + attachResult.error?.message || String(attachResult.stderr || "").trim() || "unknown error"; + log(deps.stderr, `failed to attach tmux session ${sessionName}: ${detail}`); + return attachResult.status || 1; + } + return 0; + } + deps.stdout.write(`Attach: tmux attach -t ${sessionName}\n`); + deps.stdout.write("Restart: rerun the same pnpm gateway:watch command\n"); + deps.stdout.write(`Stop: tmux kill-session -t ${sessionName}\n`); + return 0; +}; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exit(runGatewayWatchTmuxMain()); +} diff --git a/src/infra/gateway-watch-tmux.test.ts b/src/infra/gateway-watch-tmux.test.ts new file mode 100644 index 00000000000..ae9c3dc277d --- /dev/null +++ b/src/infra/gateway-watch-tmux.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildGatewayWatchTmuxCommand, + resolveGatewayWatchTmuxSessionName, + runGatewayWatchTmuxMain, +} from "../../scripts/gateway-watch-tmux.mjs"; + +const createOutput = () => { + const chunks: string[] = []; + return { + chunks, + stream: { + write: (message: string) => { + chunks.push(message); + }, + }, + }; +}; + +describe("gateway-watch tmux wrapper", () => { + it("derives stable session names from profile and port", () => { + expect(resolveGatewayWatchTmuxSessionName({ args: ["gateway", "--force"], env: {} })).toBe( + "openclaw-gateway-watch-main", + ); + expect( + resolveGatewayWatchTmuxSessionName({ + args: ["gateway", "--force", "--port", "19001"], + env: { OPENCLAW_PROFILE: "Dev Profile" }, + }), + ).toBe("openclaw-gateway-watch-dev-profile-19001"); + expect( + resolveGatewayWatchTmuxSessionName({ + args: ["--dev", "gateway", "--port=18789"], + env: {}, + }), + ).toBe("openclaw-gateway-watch-dev"); + }); + + it("builds a login-shell command that runs the raw watcher in the repo", () => { + const command = buildGatewayWatchTmuxCommand({ + args: ["gateway", "--force", "--raw-stream-path", "a b.jsonl"], + cwd: "/repo with spaces/openclaw", + env: { + OPENCLAW_GATEWAY_PORT: "19001", + OPENCLAW_PROFILE: "Dev Profile", + SHELL: "/bin/zsh", + }, + nodePath: "/opt/node", + sessionName: "openclaw-gateway-watch-main", + }); + + expect(command).toContain("exec '/bin/zsh' -lc"); + expect(command).toContain("/repo with spaces/openclaw"); + expect(command).toContain("'OPENCLAW_GATEWAY_WATCH_TMUX_CHILD=1'"); + expect(command).toContain("'OPENCLAW_GATEWAY_WATCH_SESSION=openclaw-gateway-watch-main'"); + expect(command).toContain("'OPENCLAW_GATEWAY_PORT=19001'"); + expect(command).toContain("'OPENCLAW_PROFILE=Dev Profile'"); + expect(command).toContain("/opt/node"); + expect(command).toContain("scripts/watch-node.mjs"); + expect(command).toContain("gateway"); + expect(command).toContain("--force"); + expect(command).toContain("'a b.jsonl'"); + }); + + it("creates a detached tmux session when none exists", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force"], + cwd: "/repo", + env: { SHELL: "/bin/zsh" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdout: stdout.stream, + }); + + expect(code).toBe(0); + expect(spawnSync).toHaveBeenNthCalledWith( + 1, + "tmux", + ["has-session", "-t", "openclaw-gateway-watch-main"], + expect.objectContaining({ encoding: "utf8" }), + ); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + "tmux", + [ + "new-session", + "-d", + "-s", + "openclaw-gateway-watch-main", + "-c", + "/repo", + expect.stringContaining("scripts/watch-node.mjs"), + ], + expect.objectContaining({ encoding: "utf8" }), + ); + expect(stderr.chunks.join("")).toContain( + "gateway:watch started in tmux session openclaw-gateway-watch-main", + ); + expect(stdout.chunks.join("")).toContain("tmux attach -t openclaw-gateway-watch-main"); + }); + + it("auto-attaches in an interactive terminal after creating a session", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force"], + cwd: "/repo", + env: { SHELL: "/bin/zsh" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdinIsTTY: true, + stdout: stdout.stream, + stdoutIsTTY: true, + }); + + expect(code).toBe(0); + expect(spawnSync).toHaveBeenNthCalledWith( + 3, + "tmux", + ["attach-session", "-t", "openclaw-gateway-watch-main"], + expect.objectContaining({ stdio: "inherit" }), + ); + expect(stdout.chunks.join("")).not.toContain("tmux attach -t"); + }); + + it("switches tmux clients instead of nesting attach when already inside tmux", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force"], + cwd: "/repo", + env: { SHELL: "/bin/zsh", TMUX: "/tmp/tmux-501/default,1,0" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdinIsTTY: true, + stdout: stdout.stream, + stdoutIsTTY: true, + }); + + expect(code).toBe(0); + expect(spawnSync).toHaveBeenNthCalledWith( + 3, + "tmux", + ["switch-client", "-t", "openclaw-gateway-watch-main"], + expect.objectContaining({ stdio: "inherit" }), + ); + }); + + it("keeps detached output in CI unless attach is forced", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force"], + cwd: "/repo", + env: { CI: "1", SHELL: "/bin/zsh" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdinIsTTY: true, + stdout: stdout.stream, + stdoutIsTTY: true, + }); + + expect(code).toBe(0); + expect(spawnSync).toHaveBeenCalledTimes(2); + expect(stdout.chunks.join("")).toContain("tmux attach -t openclaw-gateway-watch-main"); + }); + + it("respawns the existing tmux pane on repeated runs", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force", "--port=19001"], + cwd: "/repo", + env: { OPENCLAW_PROFILE: "dev", SHELL: "/bin/zsh" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdout: stdout.stream, + }); + + expect(code).toBe(0); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + "tmux", + [ + "respawn-pane", + "-k", + "-t", + "openclaw-gateway-watch-dev-19001", + "-c", + "/repo", + expect.stringContaining("scripts/watch-node.mjs"), + ], + expect.objectContaining({ encoding: "utf8" }), + ); + expect(stderr.chunks.join("")).toContain( + "gateway:watch restarted in tmux session openclaw-gateway-watch-dev-19001", + ); + }); + + it("recreates a stale session when its active pane target is missing", () => { + const stdout = createOutput(); + const stderr = createOutput(); + const spawnSync = vi + .fn() + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 1, stdout: "", stderr: "can't find window: 0" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }) + .mockReturnValueOnce({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force"], + cwd: "/repo", + env: { CI: "1", SHELL: "/bin/zsh" }, + nodePath: "/node", + spawnSync, + stderr: stderr.stream, + stdout: stdout.stream, + }); + + expect(code).toBe(0); + expect(spawnSync).toHaveBeenNthCalledWith( + 2, + "tmux", + [ + "respawn-pane", + "-k", + "-t", + "openclaw-gateway-watch-main", + "-c", + "/repo", + expect.stringContaining("scripts/watch-node.mjs"), + ], + expect.objectContaining({ encoding: "utf8" }), + ); + expect(spawnSync).toHaveBeenNthCalledWith( + 3, + "tmux", + ["kill-session", "-t", "openclaw-gateway-watch-main"], + expect.objectContaining({ encoding: "utf8" }), + ); + expect(spawnSync).toHaveBeenNthCalledWith( + 4, + "tmux", + [ + "new-session", + "-d", + "-s", + "openclaw-gateway-watch-main", + "-c", + "/repo", + expect.stringContaining("scripts/watch-node.mjs"), + ], + expect.objectContaining({ encoding: "utf8" }), + ); + }); + + it("runs the raw foreground watcher when tmux mode is disabled", () => { + const spawnSync = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "" }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force"], + cwd: "/repo", + env: { OPENCLAW_GATEWAY_WATCH_TMUX: "0" }, + nodePath: "/node", + spawnSync, + }); + + expect(code).toBe(0); + expect(spawnSync).toHaveBeenCalledWith( + "/node", + ["scripts/watch-node.mjs", "gateway", "--force"], + { + cwd: "/repo", + env: { OPENCLAW_GATEWAY_WATCH_TMUX: "0" }, + stdio: "inherit", + }, + ); + }); + + it("prints a raw-mode hint when tmux is unavailable", () => { + const stderr = createOutput(); + const spawnSync = vi.fn().mockReturnValue({ + error: Object.assign(new Error("spawn tmux ENOENT"), { code: "ENOENT" }), + }); + + const code = runGatewayWatchTmuxMain({ + args: ["gateway", "--force"], + cwd: "/repo", + env: {}, + spawnSync, + stderr: stderr.stream, + }); + + expect(code).toBe(1); + expect(stderr.chunks.join("")).toContain("tmux is not installed or not on PATH"); + }); +});