mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
c70aee247e
* refactor(scripts): migrate JavaScript tools to TypeScript * fix(ci): keep changed-scope preflight zero-install * fix(ci): preserve zero-install script owners * fix(ci): complete script migration follow-through * fix(release): keep stable closeout zero-install * fix(scripts): preserve standalone execution boundaries * fix(scripts): repair standalone loader boundaries * fix(scripts): normalize gateway observation ids * fix(scripts): keep Docker packager standalone * test(scripts): preserve rebase cleanup helpers * test(sessions): use tracked temp directory
640 lines
22 KiB
TypeScript
640 lines
22 KiB
TypeScript
#!/usr/bin/env node
|
|
// Starts gateway watch in tmux while preserving useful dev environment state.
|
|
import { spawnSync, type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process";
|
|
import process from "node:process";
|
|
import { pathToFileURL } from "node:url";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
|
|
type TextWriter = {
|
|
write: (message: string) => unknown;
|
|
};
|
|
|
|
type GatewaySpawn = (
|
|
command: string,
|
|
args: string[],
|
|
options: SpawnSyncOptions,
|
|
) => Partial<SpawnSyncReturns<string | Buffer>>;
|
|
|
|
type GatewayWatchParams = {
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
nodePath?: string;
|
|
sessionName?: string;
|
|
spawnSync?: GatewaySpawn;
|
|
stderr?: TextWriter;
|
|
stdinIsTTY?: boolean;
|
|
stdout?: TextWriter;
|
|
stdoutIsTTY?: boolean;
|
|
};
|
|
|
|
type GatewayWatchDeps = Required<Omit<GatewayWatchParams, "sessionName">>;
|
|
|
|
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 DEFAULT_BENCHMARK_PROFILE_DIR = ".artifacts/gateway-watch-profiles";
|
|
const DEFAULT_BENCHMARK_PROFILE_MAX_FILES = "40";
|
|
const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR";
|
|
const RUN_NODE_CPU_PROF_MAX_FILES_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES";
|
|
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
|
|
const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR";
|
|
const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs";
|
|
const RUN_NODE_SCRIPT = "scripts/run-node.mjs";
|
|
const GATEWAY_WATCH_TMUX_SCRIPT = "scripts/gateway-watch-tmux.mts";
|
|
const SERVICE_HANDOFF_ARG = "--handoff-managed-service";
|
|
const DEFAULT_GATEWAY_PORT = "18789";
|
|
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_DIAGNOSTICS",
|
|
"OPENCLAW_DIAGNOSTICS_EVENT_LOOP",
|
|
"OPENCLAW_DIAGNOSTICS_TIMELINE_PATH",
|
|
"OPENCLAW_GATEWAY_PORT",
|
|
"OPENCLAW_GATEWAY_RESTART_TRACE",
|
|
"OPENCLAW_GATEWAY_STARTUP_TRACE",
|
|
"OPENCLAW_GATEWAY_WATCH_AUTO_DOCTOR",
|
|
"OPENCLAW_HOME",
|
|
"OPENCLAW_PROFILE",
|
|
RUN_NODE_CPU_PROF_DIR_ENV,
|
|
RUN_NODE_CPU_PROF_MAX_FILES_ENV,
|
|
RUN_NODE_FILTER_SYNC_IO_STDERR_ENV,
|
|
RUN_NODE_OUTPUT_LOG_ENV,
|
|
"OPENCLAW_SKIP_CHANNELS",
|
|
"OPENCLAW_STATE_DIR",
|
|
"OPENCLAW_TRACE_SYNC_IO",
|
|
];
|
|
|
|
const sanitizeSessionPart = (value: string | number) => {
|
|
const normalized = String(value)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_.-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return normalized || DEFAULT_PROFILE_NAME;
|
|
};
|
|
|
|
const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
|
|
|
|
const readArgValue = (args: string[], flag: string): string | null => {
|
|
const prefix = `${flag}=`;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === undefined) {
|
|
continue;
|
|
}
|
|
if (arg === flag) {
|
|
const next = args[index + 1];
|
|
return typeof next === "string" && !next.startsWith("-") ? next : null;
|
|
}
|
|
if (arg?.startsWith(prefix)) {
|
|
return arg.slice(prefix.length);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const hasArg = (args: string[], flag: string): boolean =>
|
|
args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
|
|
const parsePortValue = (raw: unknown, { allowHost = false }: { allowHost?: boolean } = {}) => {
|
|
const trimmed = typeof raw === "string" || typeof raw === "number" ? String(raw).trim() : "";
|
|
let portText = /^\d+$/.test(trimmed) ? trimmed : null;
|
|
if (!portText && allowHost) {
|
|
const bracketed = trimmed.match(/^\[[^\]]+\]:(\d+)$/);
|
|
if (bracketed?.[1]) {
|
|
portText = bracketed[1];
|
|
} else {
|
|
const firstColon = trimmed.indexOf(":");
|
|
const lastColon = trimmed.lastIndexOf(":");
|
|
const suffix =
|
|
firstColon > 0 && firstColon === lastColon ? trimmed.slice(firstColon + 1) : "";
|
|
portText = /^\d+$/.test(suffix) ? suffix : null;
|
|
}
|
|
}
|
|
const port = portText ? Number(portText) : Number.NaN;
|
|
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null;
|
|
};
|
|
|
|
const resolveGatewayWatchPort = (args: string[], env: NodeJS.ProcessEnv) => {
|
|
// Keep CLI precedence and Compose-style env parsing aligned with the Gateway
|
|
// owners in src/cli/gateway-cli/run.ts and src/config/paths.ts.
|
|
if (hasArg(args, "--port")) {
|
|
return { explicitCli: true, port: parsePortValue(readArgValue(args, "--port")) };
|
|
}
|
|
return {
|
|
explicitCli: false,
|
|
port: parsePortValue(env.OPENCLAW_GATEWAY_PORT, { allowHost: true }),
|
|
};
|
|
};
|
|
|
|
const resolveGatewayWatchProfile = (args: string[], env: NodeJS.ProcessEnv) => {
|
|
if (hasArg(args, "--profile")) {
|
|
return readArgValue(args, "--profile") ?? "";
|
|
}
|
|
const gatewayIndex = args.indexOf("gateway");
|
|
const devIndex = args.indexOf("--dev");
|
|
if (devIndex >= 0 && (gatewayIndex < 0 || devIndex < gatewayIndex)) {
|
|
return "dev";
|
|
}
|
|
return env.OPENCLAW_PROFILE || null;
|
|
};
|
|
|
|
const joinArtifactPath = (dir: string | undefined, basename: string): string => {
|
|
const normalizedDir = (dir || DEFAULT_BENCHMARK_PROFILE_DIR).replace(/[\\/]+$/g, "");
|
|
return `${normalizedDir || "."}/${basename}`;
|
|
};
|
|
|
|
const resolveGatewayWatchBenchmarkArgs = ({
|
|
args = [],
|
|
env = process.env,
|
|
}: Pick<GatewayWatchParams, "args" | "env"> = {}) => {
|
|
const passthroughArgs: string[] = [];
|
|
let benchmarkDir: string | null = null;
|
|
let benchmarkFlagSeen = false;
|
|
let benchmarkNoForceSeen = false;
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === undefined) {
|
|
continue;
|
|
}
|
|
if (arg === "--benchmark") {
|
|
benchmarkFlagSeen = true;
|
|
benchmarkDir ??= DEFAULT_BENCHMARK_PROFILE_DIR;
|
|
continue;
|
|
}
|
|
if (arg === "--benchmark-no-force") {
|
|
benchmarkFlagSeen = true;
|
|
benchmarkNoForceSeen = true;
|
|
benchmarkDir ??= DEFAULT_BENCHMARK_PROFILE_DIR;
|
|
continue;
|
|
}
|
|
if (arg?.startsWith("--benchmark=")) {
|
|
benchmarkFlagSeen = true;
|
|
benchmarkDir = arg.slice("--benchmark=".length) || DEFAULT_BENCHMARK_PROFILE_DIR;
|
|
continue;
|
|
}
|
|
if (arg === "--benchmark-dir") {
|
|
benchmarkFlagSeen = true;
|
|
const next = args[index + 1];
|
|
if (typeof next === "string" && !next.startsWith("-")) {
|
|
benchmarkDir = next;
|
|
index += 1;
|
|
} else {
|
|
benchmarkDir ??= DEFAULT_BENCHMARK_PROFILE_DIR;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg?.startsWith("--benchmark-dir=")) {
|
|
benchmarkFlagSeen = true;
|
|
benchmarkDir = arg.slice("--benchmark-dir=".length) || DEFAULT_BENCHMARK_PROFILE_DIR;
|
|
continue;
|
|
}
|
|
passthroughArgs.push(arg);
|
|
}
|
|
|
|
const nextEnv = { ...env };
|
|
if (benchmarkFlagSeen) {
|
|
const benchmarkProfileDir =
|
|
benchmarkDir || nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || DEFAULT_BENCHMARK_PROFILE_DIR;
|
|
nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] = benchmarkProfileDir;
|
|
nextEnv[RUN_NODE_CPU_PROF_MAX_FILES_ENV] ??= DEFAULT_BENCHMARK_PROFILE_MAX_FILES;
|
|
nextEnv.OPENCLAW_TRACE_SYNC_IO ??= "0";
|
|
if (nextEnv.OPENCLAW_TRACE_SYNC_IO === "1") {
|
|
nextEnv[RUN_NODE_OUTPUT_LOG_ENV] ??= joinArtifactPath(
|
|
benchmarkProfileDir,
|
|
"gateway-watch-output.log",
|
|
);
|
|
nextEnv[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] ??= "1";
|
|
}
|
|
}
|
|
return {
|
|
args: benchmarkNoForceSeen
|
|
? passthroughArgs.filter((arg) => arg !== "--force")
|
|
: passthroughArgs,
|
|
benchmarkNoForce: benchmarkNoForceSeen,
|
|
benchmarkProfileDir: nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || null,
|
|
benchmarkTraceOutputLog:
|
|
nextEnv[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] === "1"
|
|
? nextEnv[RUN_NODE_OUTPUT_LOG_ENV] || null
|
|
: null,
|
|
env: nextEnv,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Resolves the tmux session name for gateway watch arguments/environment.
|
|
*/
|
|
export const resolveGatewayWatchTmuxSessionName = ({
|
|
args = [],
|
|
env = process.env,
|
|
}: Pick<GatewayWatchParams, "args" | "env"> = {}): string => {
|
|
const profile = resolveGatewayWatchProfile(args, env);
|
|
const { port } = resolveGatewayWatchPort(args, env);
|
|
const parts = [
|
|
"openclaw",
|
|
"gateway",
|
|
"watch",
|
|
sanitizeSessionPart(profile ?? DEFAULT_PROFILE_NAME),
|
|
];
|
|
if (port && String(port) !== DEFAULT_GATEWAY_PORT) {
|
|
parts.push(sanitizeSessionPart(port));
|
|
}
|
|
return parts.join("-");
|
|
};
|
|
|
|
const resolveShell = (env: NodeJS.ProcessEnv): string => env.SHELL || "/bin/sh";
|
|
|
|
const resolveColorEnv = (env: NodeJS.ProcessEnv) => {
|
|
const forceColor = env.FORCE_COLOR;
|
|
if (forceColor == null || forceColor === "") {
|
|
return { assignments: ["FORCE_COLOR=1"], options: ["-u", "NO_COLOR"] };
|
|
}
|
|
if (forceColor.trim() !== "0") {
|
|
return { assignments: [`FORCE_COLOR=${forceColor}`], options: ["-u", "NO_COLOR"] };
|
|
}
|
|
return { assignments: [`FORCE_COLOR=${forceColor}`], options: [] };
|
|
};
|
|
|
|
/**
|
|
* Builds the shell command executed inside the tmux gateway watch session.
|
|
*/
|
|
export const buildGatewayWatchTmuxCommand = ({
|
|
args = [],
|
|
cwd = process.cwd(),
|
|
env = process.env,
|
|
nodePath = process.execPath,
|
|
sessionName,
|
|
}: GatewayWatchParams = {}): string => {
|
|
const shell = resolveShell(env);
|
|
const colorEnv = resolveColorEnv(env);
|
|
// tmux sessions retain their own environment across respawns. Clear supported
|
|
// selectors before applying the invoking process's current values.
|
|
const childEnv = [
|
|
"env",
|
|
...colorEnv.options,
|
|
...TMUX_CHILD_ENV_KEYS.flatMap((key) => ["-u", key]),
|
|
`OPENCLAW_GATEWAY_WATCH_TMUX_CHILD=1`,
|
|
`OPENCLAW_GATEWAY_WATCH_SESSION=${sessionName}`,
|
|
...colorEnv.assignments,
|
|
...TMUX_CHILD_ENV_KEYS.flatMap((key) =>
|
|
env[key] == null || env[key] === "" ? [] : [`${key}=${env[key]}`],
|
|
),
|
|
];
|
|
const childEnvCommand = childEnv.map(shellQuote);
|
|
const handoffCommand = [
|
|
...childEnvCommand,
|
|
shellQuote(nodePath),
|
|
"--import",
|
|
"tsx",
|
|
shellQuote(GATEWAY_WATCH_TMUX_SCRIPT),
|
|
shellQuote(SERVICE_HANDOFF_ARG),
|
|
...args.map(shellQuote),
|
|
"&&",
|
|
];
|
|
const watchCommand = [
|
|
"cd",
|
|
shellQuote(cwd),
|
|
"&&",
|
|
...handoffCommand,
|
|
"exec",
|
|
...childEnvCommand,
|
|
shellQuote(nodePath),
|
|
shellQuote(RAW_WATCH_SCRIPT),
|
|
...args.map(shellQuote),
|
|
].join(" ");
|
|
return `exec ${shellQuote(shell)} -lc ${shellQuote(watchCommand)}`;
|
|
};
|
|
|
|
const parseTrailingJsonObject = (
|
|
raw: string | Buffer | null | undefined,
|
|
): Record<string, unknown> | null => {
|
|
const text = (Buffer.isBuffer(raw) ? raw.toString("utf8") : (raw ?? "")).trim();
|
|
for (let index = text.lastIndexOf("{"); index >= 0; index = text.lastIndexOf("{", index - 1)) {
|
|
try {
|
|
const parsed: unknown = JSON.parse(text.slice(index));
|
|
if (isRecord(parsed)) {
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
// Build output can precede the CLI JSON; keep scanning for the outer object.
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/** Stops the matching managed service without targeting an unrelated listener. */
|
|
export const runGatewayWatchServiceHandoff = (params: GatewayWatchParams = {}): number => {
|
|
const args = params.args ?? [];
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const env = params.env ? { ...params.env } : { ...process.env };
|
|
const nodePath = params.nodePath ?? process.execPath;
|
|
const spawnSyncImpl: GatewaySpawn = params.spawnSync ?? spawnSync;
|
|
const stderr = params.stderr ?? process.stderr;
|
|
const profile = resolveGatewayWatchProfile(args, env);
|
|
const profileArgs = profile === null ? [] : ["--profile", profile];
|
|
const statusResult = spawnSyncImpl(
|
|
nodePath,
|
|
[RUN_NODE_SCRIPT, ...profileArgs, "gateway", "status", "--json", "--no-probe"],
|
|
{
|
|
cwd,
|
|
env,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
if (statusResult.error || statusResult.status !== 0) {
|
|
const detail =
|
|
statusResult.error?.message || String(statusResult.stderr || "").trim() || "unknown error";
|
|
log(stderr, `failed to inspect the managed Gateway service before watch: ${detail}`);
|
|
return statusResult.status || 1;
|
|
}
|
|
const status = parseTrailingJsonObject(statusResult.stdout);
|
|
if (!status || typeof status !== "object") {
|
|
log(stderr, "failed to parse managed Gateway service status before watch");
|
|
return 1;
|
|
}
|
|
const service = isRecord(status.service) ? status.service : undefined;
|
|
const serviceRuntime = isRecord(service?.runtime) ? service.runtime : undefined;
|
|
const gateway = isRecord(status.gateway) ? status.gateway : undefined;
|
|
const portCli = isRecord(status.portCli) ? status.portCli : undefined;
|
|
const managedServiceActive = service?.loaded === true || serviceRuntime?.status === "running";
|
|
if (!managedServiceActive) {
|
|
return 0;
|
|
}
|
|
|
|
const { explicitCli, port: requestedPort } = resolveGatewayWatchPort(args, env);
|
|
if (explicitCli && requestedPort === null) {
|
|
// The watcher will report the invalid CLI value without disrupting a healthy service.
|
|
return 0;
|
|
}
|
|
const managedPort = parsePortValue(gateway?.port);
|
|
const currentConfigPort = parsePortValue(portCli?.port ?? gateway?.port);
|
|
const watchPort = requestedPort ?? currentConfigPort;
|
|
if (managedPort === null || watchPort === null) {
|
|
log(stderr, "failed to resolve the Gateway watch port before service handoff");
|
|
return 1;
|
|
}
|
|
if (watchPort !== managedPort) {
|
|
log(
|
|
stderr,
|
|
`gateway:watch leaving managed Gateway on port ${managedPort}; watching port ${watchPort}`,
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
// If the service unloads after status, keep the unmanaged fallback scoped to
|
|
// the watch target instead of a lower-precedence environment port.
|
|
const stopEnv = { ...env };
|
|
if (explicitCli) {
|
|
stopEnv.OPENCLAW_GATEWAY_PORT = String(watchPort);
|
|
}
|
|
const stopResult = spawnSyncImpl(nodePath, [RUN_NODE_SCRIPT, ...profileArgs, "gateway", "stop"], {
|
|
cwd,
|
|
env: stopEnv,
|
|
stdio: "inherit",
|
|
});
|
|
if (stopResult.error) {
|
|
log(
|
|
stderr,
|
|
`failed to stop the managed Gateway service before watch: ${stopResult.error.message}`,
|
|
);
|
|
return 1;
|
|
}
|
|
return stopResult.status ?? (stopResult.signal ? 1 : 0);
|
|
};
|
|
|
|
const runForegroundWatcher = (
|
|
deps: Pick<GatewayWatchDeps, "args" | "cwd" | "env" | "nodePath" | "spawnSync">,
|
|
) => {
|
|
const result = deps.spawnSync(deps.nodePath, [RAW_WATCH_SCRIPT, ...deps.args], {
|
|
cwd: deps.cwd,
|
|
env: deps.env,
|
|
stdio: "inherit",
|
|
});
|
|
return result.status ?? (result.signal ? 1 : 0);
|
|
};
|
|
|
|
const runTmux = (
|
|
spawnSyncImpl: GatewaySpawn,
|
|
args: string[],
|
|
options: { stdio?: "inherit" } = {},
|
|
) =>
|
|
spawnSyncImpl("tmux", args, {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
...options,
|
|
});
|
|
|
|
const log = (stderr: TextWriter, message: string): void => {
|
|
stderr.write(`[openclaw] ${message}\n`);
|
|
};
|
|
|
|
const getTmuxErrorText = (result: ReturnType<typeof runTmux>): string =>
|
|
result.error?.message || String(result.stderr || "").trim() || "unknown error";
|
|
|
|
const isNodeErrorCode = (error: Error | undefined, code: string): boolean =>
|
|
error !== undefined && "code" in error && error.code === code;
|
|
|
|
const isMissingTmuxTarget = (result: ReturnType<typeof runTmux>): boolean =>
|
|
/can't find (?:session|window|pane)|no current target/i.test(getTmuxErrorText(result));
|
|
|
|
const shouldAttachTmux = (deps: Pick<GatewayWatchDeps, "env" | "stdinIsTTY" | "stdoutIsTTY">) => {
|
|
const raw = (deps.env.OPENCLAW_GATEWAY_WATCH_ATTACH ?? "").toLowerCase();
|
|
if (TMUX_ATTACH_FORCE_VALUES.has(raw)) {
|
|
return true;
|
|
}
|
|
if (TMUX_ATTACH_DISABLE_VALUES.has(raw)) {
|
|
return false;
|
|
}
|
|
// TERM=dumb pseudo-TTYs cannot satisfy tmux's clear/cursor requirements.
|
|
const term = (deps.env.TERM ?? "").trim().toLowerCase();
|
|
return (
|
|
!deps.env.CI &&
|
|
deps.stdinIsTTY &&
|
|
deps.stdoutIsTTY &&
|
|
(Boolean(deps.env.TMUX) || (term !== "" && term !== "dumb"))
|
|
);
|
|
};
|
|
|
|
const attachTmux = (deps: Pick<GatewayWatchDeps, "env" | "spawnSync">, sessionName: string) => {
|
|
const args = deps.env.TMUX
|
|
? ["switch-client", "-t", sessionName]
|
|
: ["attach-session", "-t", sessionName];
|
|
return runTmux(deps.spawnSync, args, { stdio: "inherit" });
|
|
};
|
|
|
|
const setTmuxSessionMetadata = (
|
|
deps: Pick<GatewayWatchDeps, "cwd" | "spawnSync" | "stderr">,
|
|
sessionName: string,
|
|
) => {
|
|
const updates = [
|
|
["set-option", "-q", "-t", sessionName, TMUX_CWD_OPTION_KEY, deps.cwd],
|
|
["set-environment", "-t", sessionName, TMUX_CWD_ENV_KEY, deps.cwd],
|
|
];
|
|
for (const args of updates) {
|
|
const result = runTmux(deps.spawnSync, args);
|
|
if (result.error || result.status !== 0) {
|
|
log(
|
|
deps.stderr,
|
|
`warning: failed to update tmux session metadata: ${getTmuxErrorText(result)}`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
const retainTmuxPaneOnExit = (spawnSyncImpl: GatewaySpawn, sessionName: string) =>
|
|
runTmux(spawnSyncImpl, ["set-option", "-w", "-t", sessionName, "remain-on-exit", "on"]);
|
|
|
|
/**
|
|
* Runs the gateway-watch tmux wrapper main flow.
|
|
*/
|
|
export const runGatewayWatchTmuxMain = (params: GatewayWatchParams = {}): number => {
|
|
const resolvedArgs = resolveGatewayWatchBenchmarkArgs({
|
|
args: params.args ?? process.argv.slice(2),
|
|
env: params.env ? { ...params.env } : { ...process.env },
|
|
});
|
|
const deps = {
|
|
args: resolvedArgs.args,
|
|
cwd: params.cwd ?? process.cwd(),
|
|
env: resolvedArgs.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,
|
|
} satisfies GatewayWatchDeps;
|
|
|
|
if (resolvedArgs.benchmarkProfileDir) {
|
|
log(deps.stderr, `gateway:watch benchmark CPU profiles: ${resolvedArgs.benchmarkProfileDir}`);
|
|
}
|
|
if (resolvedArgs.benchmarkTraceOutputLog) {
|
|
log(
|
|
deps.stderr,
|
|
`gateway:watch benchmark trace output: ${resolvedArgs.benchmarkTraceOutputLog}`,
|
|
);
|
|
}
|
|
if (resolvedArgs.benchmarkNoForce) {
|
|
log(deps.stderr, "gateway:watch benchmark running without --force");
|
|
}
|
|
|
|
if (TMUX_DISABLE_VALUES.has((deps.env.OPENCLAW_GATEWAY_WATCH_TMUX ?? "").toLowerCase())) {
|
|
return runForegroundWatcher(deps);
|
|
}
|
|
|
|
if (deps.env.OPENCLAW_GATEWAY_WATCH_TMUX_CHILD === "1") {
|
|
return runForegroundWatcher(deps);
|
|
}
|
|
|
|
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 (isNodeErrorCode(hasSession.error, "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 launchPane = () =>
|
|
runTmux(deps.spawnSync, ["respawn-pane", "-k", "-t", sessionName, "-c", deps.cwd, command]);
|
|
const prepareSession = () => retainTmuxPaneOnExit(deps.spawnSync, sessionName);
|
|
const startSession = () => {
|
|
// Create a durable shell pane first so remain-on-exit is active before the
|
|
// watcher can fail. Agents can then capture the original startup error.
|
|
const created = runTmux(deps.spawnSync, [
|
|
"new-session",
|
|
"-d",
|
|
"-s",
|
|
sessionName,
|
|
"-c",
|
|
deps.cwd,
|
|
]);
|
|
if (created.error || created.status !== 0) {
|
|
return created;
|
|
}
|
|
const prepared = prepareSession();
|
|
if (prepared.error || prepared.status !== 0) {
|
|
runTmux(deps.spawnSync, ["kill-session", "-t", sessionName]);
|
|
return prepared;
|
|
}
|
|
return launchPane();
|
|
};
|
|
const restartSession = () => {
|
|
const prepared = prepareSession();
|
|
if (prepared.error || prepared.status !== 0) {
|
|
return prepared;
|
|
}
|
|
return launchPane();
|
|
};
|
|
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 (isNodeErrorCode(result.error, "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(deps, sessionName);
|
|
|
|
log(deps.stderr, `gateway:watch ${action} in tmux session ${sessionName}`);
|
|
if (shouldAttachTmux(deps)) {
|
|
const attachResult = attachTmux(deps, sessionName);
|
|
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(`Logs: tmux capture-pane -ep -t ${sessionName} -S -200\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) {
|
|
const args = process.argv.slice(2);
|
|
process.exit(
|
|
args[0] === SERVICE_HANDOFF_ARG
|
|
? runGatewayWatchServiceHandoff({ args: args.slice(1) })
|
|
: runGatewayWatchTmuxMain({ args }),
|
|
);
|
|
}
|