chore(gateway): run watch mode in tmux

This commit is contained in:
Peter Steinberger
2026-04-29 09:42:48 +01:00
parent 4fbd683819
commit 68ba1e7180
7 changed files with 660 additions and 6 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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",

View File

@@ -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;

View File

@@ -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());
}

View File

@@ -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");
});
});