#!/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_CWD_ENV_KEY = "OPENCLAW_GATEWAY_WATCH_CWD"; const TMUX_CWD_OPTION_KEY = "@openclaw.gateway_watch.cwd"; 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" }); }; const setTmuxSessionMetadata = ({ cwd, sessionName, spawnSyncImpl, stderr }) => { const updates = [ ["set-option", "-q", "-t", sessionName, TMUX_CWD_OPTION_KEY, cwd], ["set-environment", "-t", sessionName, TMUX_CWD_ENV_KEY, cwd], ]; for (const args of updates) { const result = runTmux(spawnSyncImpl, args); if (result.error || result.status !== 0) { log(stderr, `warning: failed to update tmux session metadata: ${getTmuxErrorText(result)}`); return; } } }; 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; } setTmuxSessionMetadata({ cwd: deps.cwd, sessionName, spawnSyncImpl: deps.spawnSync, stderr: deps.stderr, }); 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(`Cwd: tmux show-options -v -t ${sessionName} ${TMUX_CWD_OPTION_KEY}\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()); }