Skip to content

Commit 68ba1e7

Browse files
committed
chore(gateway): run watch mode in tmux
1 parent 4fbd683 commit 68ba1e7

7 files changed

Lines changed: 660 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- 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.
910
- 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.
1011
- 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.
1112
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.

docs/help/debugging.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,21 +216,52 @@ For fast iteration, run the gateway under the file watcher:
216216
pnpm gateway:watch
217217
```
218218

219-
This maps to:
219+
By default, this starts or restarts a tmux session named
220+
`openclaw-gateway-watch-main` (or a profile/port-specific variant such as
221+
`openclaw-gateway-watch-dev-19001`) and auto-attaches from interactive terminals.
222+
Non-interactive shells, CI, and agent exec calls stay detached and print attach
223+
instructions instead. Attach manually when needed:
224+
225+
```bash
226+
tmux attach -t openclaw-gateway-watch-main
227+
```
228+
229+
The tmux pane runs the raw watcher:
220230

221231
```bash
222232
node scripts/watch-node.mjs gateway --force
223233
```
224234

235+
Use foreground mode when tmux is not wanted:
236+
237+
```bash
238+
pnpm gateway:watch:raw
239+
# or
240+
OPENCLAW_GATEWAY_WATCH_TMUX=0 pnpm gateway:watch
241+
```
242+
243+
Disable auto-attach while keeping tmux management:
244+
245+
```bash
246+
OPENCLAW_GATEWAY_WATCH_ATTACH=0 pnpm gateway:watch
247+
```
248+
249+
The tmux wrapper carries common non-secret runtime selectors such as
250+
`OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR`,
251+
`OPENCLAW_GATEWAY_PORT`, and `OPENCLAW_SKIP_CHANNELS` into the pane. Put
252+
provider credentials in your normal profile/config, or use raw foreground mode
253+
for one-off ephemeral secrets.
254+
225255
The watcher restarts on build-relevant files under `src/`, extension source files,
226256
extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`,
227257
`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the
228258
gateway without forcing a `tsdown` rebuild; source and config changes still
229259
rebuild `dist` first.
230260

231261
Add any gateway CLI flags after `gateway:watch` and they will be passed through on
232-
each restart. Re-running the same watch command for the same repo/flag set now
233-
replaces the older watcher instead of leaving duplicate watcher parents behind.
262+
each restart. Re-running the same watch command respawns the named tmux pane, and
263+
the raw watcher still keeps its single-watcher lock so duplicate watcher parents
264+
are replaced instead of piling up.
234265

235266
## Dev profile + dev gateway (--dev)
236267

docs/start/setup.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,12 @@ pnpm openclaw setup
9696
pnpm gateway:watch
9797
```
9898

99-
`gateway:watch` runs the gateway in watch mode and reloads on relevant source,
100-
config, and bundled-plugin metadata changes.
99+
`gateway:watch` starts or restarts the Gateway watch process in a named tmux
100+
session and auto-attaches from interactive terminals. Non-interactive shells stay
101+
detached and print `tmux attach -t openclaw-gateway-watch-main`; use
102+
`OPENCLAW_GATEWAY_WATCH_ATTACH=0 pnpm gateway:watch` to keep an interactive run
103+
detached, or `pnpm gateway:watch:raw` for foreground watch mode. The watcher
104+
reloads on relevant source, config, and bundled-plugin metadata changes.
101105
`pnpm openclaw setup` is the one-time local config/workspace initialization step for a fresh checkout.
102106
`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.
103107

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1334,7 +1334,8 @@
13341334
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/OpenClawKit/Sources",
13351335
"gateway:dev": "OPENCLAW_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
13361336
"gateway:dev:reset": "OPENCLAW_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
1337-
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
1337+
"gateway:watch": "node scripts/gateway-watch-tmux.mjs gateway --force",
1338+
"gateway:watch:raw": "node scripts/watch-node.mjs gateway --force",
13381339
"gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write",
13391340
"ghsa:patch": "node scripts/ghsa-patch.mjs",
13401341
"ios:beta": "bash scripts/ios-beta-release.sh",

scripts/gateway-watch-tmux.d.mts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export function resolveGatewayWatchTmuxSessionName(params?: {
2+
args?: string[];
3+
env?: NodeJS.ProcessEnv;
4+
}): string;
5+
6+
export function buildGatewayWatchTmuxCommand(params?: {
7+
args?: string[];
8+
cwd?: string;
9+
env?: NodeJS.ProcessEnv;
10+
nodePath?: string;
11+
sessionName?: string;
12+
}): string;
13+
14+
export function runGatewayWatchTmuxMain(params?: {
15+
args?: string[];
16+
cwd?: string;
17+
env?: NodeJS.ProcessEnv;
18+
nodePath?: string;
19+
sessionName?: string;
20+
spawnSync?: (
21+
cmd: string,
22+
args: string[],
23+
options: unknown,
24+
) => {
25+
error?: NodeJS.ErrnoException;
26+
signal?: NodeJS.Signals | null;
27+
status?: number | null;
28+
stderr?: string;
29+
stdout?: string;
30+
};
31+
stderr?: { write: (message: string) => void };
32+
stdinIsTTY?: boolean;
33+
stdout?: { write: (message: string) => void };
34+
stdoutIsTTY?: boolean;
35+
}): number;

scripts/gateway-watch-tmux.mjs

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from "node:child_process";
3+
import process from "node:process";
4+
import { pathToFileURL } from "node:url";
5+
6+
const TMUX_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
7+
const TMUX_ATTACH_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
8+
const TMUX_ATTACH_FORCE_VALUES = new Set(["1", "true", "yes", "on"]);
9+
const DEFAULT_PROFILE_NAME = "main";
10+
const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs";
11+
const TMUX_CHILD_ENV_KEYS = [
12+
"NODE_OPTIONS",
13+
"OPENCLAW_CONFIG_PATH",
14+
"OPENCLAW_GATEWAY_PORT",
15+
"OPENCLAW_HOME",
16+
"OPENCLAW_PROFILE",
17+
"OPENCLAW_SKIP_CHANNELS",
18+
"OPENCLAW_STATE_DIR",
19+
];
20+
21+
const sanitizeSessionPart = (value) => {
22+
const normalized = String(value ?? "")
23+
.trim()
24+
.toLowerCase()
25+
.replace(/[^a-z0-9_.-]+/g, "-")
26+
.replace(/^-+|-+$/g, "");
27+
return normalized || DEFAULT_PROFILE_NAME;
28+
};
29+
30+
const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
31+
32+
const readArgValue = (args, flag) => {
33+
const prefix = `${flag}=`;
34+
for (let index = 0; index < args.length; index += 1) {
35+
const arg = args[index];
36+
if (arg === flag) {
37+
const next = args[index + 1];
38+
return typeof next === "string" && !next.startsWith("-") ? next : null;
39+
}
40+
if (typeof arg === "string" && arg.startsWith(prefix)) {
41+
return arg.slice(prefix.length);
42+
}
43+
}
44+
return null;
45+
};
46+
47+
export const resolveGatewayWatchTmuxSessionName = ({ args = [], env = process.env } = {}) => {
48+
const profile =
49+
env.OPENCLAW_PROFILE ||
50+
readArgValue(args, "--profile") ||
51+
(args.includes("--dev") ? "dev" : null);
52+
const port = env.OPENCLAW_GATEWAY_PORT || readArgValue(args, "--port");
53+
const parts = [
54+
"openclaw",
55+
"gateway",
56+
"watch",
57+
sanitizeSessionPart(profile ?? DEFAULT_PROFILE_NAME),
58+
];
59+
if (port && port !== "18789") {
60+
parts.push(sanitizeSessionPart(port));
61+
}
62+
return parts.join("-");
63+
};
64+
65+
const resolveShell = (env) => env.SHELL || "/bin/sh";
66+
67+
export const buildGatewayWatchTmuxCommand = ({
68+
args = [],
69+
cwd = process.cwd(),
70+
env = process.env,
71+
nodePath = process.execPath,
72+
sessionName,
73+
} = {}) => {
74+
const shell = resolveShell(env);
75+
const childEnv = [
76+
"env",
77+
`OPENCLAW_GATEWAY_WATCH_TMUX_CHILD=1`,
78+
`OPENCLAW_GATEWAY_WATCH_SESSION=${sessionName}`,
79+
...TMUX_CHILD_ENV_KEYS.flatMap((key) =>
80+
env[key] == null || env[key] === "" ? [] : [`${key}=${env[key]}`],
81+
),
82+
];
83+
const watchCommand = [
84+
"cd",
85+
shellQuote(cwd),
86+
"&&",
87+
"exec",
88+
...childEnv.map(shellQuote),
89+
shellQuote(nodePath),
90+
shellQuote(RAW_WATCH_SCRIPT),
91+
...args.map(shellQuote),
92+
].join(" ");
93+
return `exec ${shellQuote(shell)} -lc ${shellQuote(watchCommand)}`;
94+
};
95+
96+
const runForegroundWatcher = ({ args, cwd, env, nodePath, spawnSyncImpl, stdio = "inherit" }) => {
97+
const result = spawnSyncImpl(nodePath, [RAW_WATCH_SCRIPT, ...args], {
98+
cwd,
99+
env,
100+
stdio,
101+
});
102+
return result.status ?? (result.signal ? 1 : 0);
103+
};
104+
105+
const runTmux = (spawnSyncImpl, args, options = {}) =>
106+
spawnSyncImpl("tmux", args, {
107+
encoding: "utf8",
108+
stdio: ["ignore", "pipe", "pipe"],
109+
...options,
110+
});
111+
112+
const log = (stderr, message) => {
113+
stderr.write(`[openclaw] ${message}\n`);
114+
};
115+
116+
const getTmuxErrorText = (result) =>
117+
result.error?.message || String(result.stderr || "").trim() || "unknown error";
118+
119+
const isMissingTmuxTarget = (result) =>
120+
/can't find (?:session|window|pane)|no current target/i.test(getTmuxErrorText(result));
121+
122+
const shouldAttachTmux = ({ env, stdinIsTTY, stdoutIsTTY }) => {
123+
const raw = String(env.OPENCLAW_GATEWAY_WATCH_ATTACH ?? "").toLowerCase();
124+
if (TMUX_ATTACH_FORCE_VALUES.has(raw)) {
125+
return true;
126+
}
127+
if (TMUX_ATTACH_DISABLE_VALUES.has(raw)) {
128+
return false;
129+
}
130+
return !env.CI && stdinIsTTY === true && stdoutIsTTY === true;
131+
};
132+
133+
const attachTmux = ({ env, sessionName, spawnSyncImpl }) => {
134+
const args = env.TMUX
135+
? ["switch-client", "-t", sessionName]
136+
: ["attach-session", "-t", sessionName];
137+
return runTmux(spawnSyncImpl, args, { stdio: "inherit" });
138+
};
139+
140+
export const runGatewayWatchTmuxMain = (params = {}) => {
141+
const deps = {
142+
args: params.args ?? process.argv.slice(2),
143+
cwd: params.cwd ?? process.cwd(),
144+
env: params.env ? { ...params.env } : { ...process.env },
145+
nodePath: params.nodePath ?? process.execPath,
146+
spawnSync: params.spawnSync ?? spawnSync,
147+
stderr: params.stderr ?? process.stderr,
148+
stdinIsTTY: params.stdinIsTTY ?? process.stdin.isTTY,
149+
stdout: params.stdout ?? process.stdout,
150+
stdoutIsTTY: params.stdoutIsTTY ?? process.stdout.isTTY,
151+
};
152+
153+
if (TMUX_DISABLE_VALUES.has(String(deps.env.OPENCLAW_GATEWAY_WATCH_TMUX ?? "").toLowerCase())) {
154+
return runForegroundWatcher({
155+
args: deps.args,
156+
cwd: deps.cwd,
157+
env: deps.env,
158+
nodePath: deps.nodePath,
159+
spawnSyncImpl: deps.spawnSync,
160+
});
161+
}
162+
163+
if (deps.env.OPENCLAW_GATEWAY_WATCH_TMUX_CHILD === "1") {
164+
return runForegroundWatcher({
165+
args: deps.args,
166+
cwd: deps.cwd,
167+
env: deps.env,
168+
nodePath: deps.nodePath,
169+
spawnSyncImpl: deps.spawnSync,
170+
});
171+
}
172+
173+
const sessionName =
174+
params.sessionName ?? resolveGatewayWatchTmuxSessionName({ args: deps.args, env: deps.env });
175+
const command = buildGatewayWatchTmuxCommand({
176+
args: deps.args,
177+
cwd: deps.cwd,
178+
env: deps.env,
179+
nodePath: deps.nodePath,
180+
sessionName,
181+
});
182+
183+
const hasSession = runTmux(deps.spawnSync, ["has-session", "-t", sessionName]);
184+
if (hasSession.error?.code === "ENOENT") {
185+
log(
186+
deps.stderr,
187+
"tmux is not installed or not on PATH; run `pnpm gateway:watch:raw` for foreground watch mode.",
188+
);
189+
return 1;
190+
}
191+
if (hasSession.error) {
192+
log(deps.stderr, `failed to query tmux session ${sessionName}: ${hasSession.error.message}`);
193+
return 1;
194+
}
195+
196+
const startSession = () =>
197+
runTmux(deps.spawnSync, ["new-session", "-d", "-s", sessionName, "-c", deps.cwd, command]);
198+
const restartSession = () =>
199+
runTmux(deps.spawnSync, ["respawn-pane", "-k", "-t", sessionName, "-c", deps.cwd, command]);
200+
const action = hasSession.status === 0 ? "restarted" : "started";
201+
let result = hasSession.status === 0 ? restartSession() : startSession();
202+
if (hasSession.status === 0 && isMissingTmuxTarget(result)) {
203+
runTmux(deps.spawnSync, ["kill-session", "-t", sessionName]);
204+
result = startSession();
205+
}
206+
if (result.error?.code === "ENOENT") {
207+
log(
208+
deps.stderr,
209+
"tmux is not installed or not on PATH; run `pnpm gateway:watch:raw` for foreground watch mode.",
210+
);
211+
return 1;
212+
}
213+
if (result.error || result.status !== 0) {
214+
const detail = getTmuxErrorText(result);
215+
log(
216+
deps.stderr,
217+
`failed to ${action === "started" ? "start" : "restart"} tmux session ${sessionName}: ${detail}`,
218+
);
219+
return result.status || 1;
220+
}
221+
222+
log(deps.stderr, `gateway:watch ${action} in tmux session ${sessionName}`);
223+
if (
224+
shouldAttachTmux({
225+
env: deps.env,
226+
stdinIsTTY: deps.stdinIsTTY,
227+
stdoutIsTTY: deps.stdoutIsTTY,
228+
})
229+
) {
230+
const attachResult = attachTmux({
231+
env: deps.env,
232+
sessionName,
233+
spawnSyncImpl: deps.spawnSync,
234+
});
235+
if (attachResult.error || attachResult.status !== 0) {
236+
const detail =
237+
attachResult.error?.message || String(attachResult.stderr || "").trim() || "unknown error";
238+
log(deps.stderr, `failed to attach tmux session ${sessionName}: ${detail}`);
239+
return attachResult.status || 1;
240+
}
241+
return 0;
242+
}
243+
deps.stdout.write(`Attach: tmux attach -t ${sessionName}\n`);
244+
deps.stdout.write("Restart: rerun the same pnpm gateway:watch command\n");
245+
deps.stdout.write(`Stop: tmux kill-session -t ${sessionName}\n`);
246+
return 0;
247+
};
248+
249+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
250+
process.exit(runGatewayWatchTmuxMain());
251+
}

0 commit comments

Comments
 (0)