Files
openclaw/scripts/gateway-watch-tmux.mts
Peter Steinberger c70aee247e refactor(scripts): migrate JavaScript tools to TypeScript (#121005)
* 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
2026-08-09 07:21:35 -07:00

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 }),
);
}