create-openclaw-bot 5.15.4 → 5.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  *Run one command → open the dashboard → your bot is live. Windows · macOS · Linux · VPS — Docker-powered, auto-installed for you.*
8
8
 
9
9
  <p align="center">
10
- <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.4-0EA5E9?style=for-the-badge" alt="Version 5.15.4" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.16.1-0EA5E9?style=for-the-badge" alt="Version 5.16.1" /></a>
11
11
  <a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
12
12
  <a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
13
13
  <a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
@@ -37,7 +37,7 @@
37
37
 
38
38
  ---
39
39
 
40
- ## 🆕 What's New in v5.15.4
40
+ ## 🆕 What's New in v5.16.1
41
41
 
42
42
  - 🖥️ **"Control PC" — let the bot open Chrome & apps on your machine**: a new toggle on each bot card turns on the ability for the bot to open Chrome or an allow-listed app (TeamViewer, Zalo, …) on the computer running it. **Off by default**, token-gated, allow-list only, desktop-only.
43
43
  - 🌐 **Browsing works out of the box on every OS**: the bot drives your real Chrome on a desktop (your logged-in profile, so sites don't flag it) and starts its own headless Chromium on a server — no more "no browser available" just because Chrome wasn't running.
package/README.vi.md CHANGED
@@ -7,7 +7,7 @@
7
7
  *Chạy một lệnh → mở dashboard → bot lên sóng. Windows · macOS · Linux · VPS — chạy trên Docker, tự cài giúp bạn.*
8
8
 
9
9
  <p align="center">
10
- <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.4-0EA5E9?style=for-the-badge" alt="Version 5.15.4" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.16.1-0EA5E9?style=for-the-badge" alt="Version 5.16.1" /></a>
11
11
  <a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
12
12
  <a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
13
13
  <a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
@@ -37,7 +37,7 @@
37
37
 
38
38
  ---
39
39
 
40
- ## 🆕 Có gì mới trong v5.15.4
40
+ ## 🆕 Có gì mới trong v5.16.1
41
41
 
42
42
  - 🖥️ **"Điều khiển máy" — cho bot mở Chrome & ứng dụng trên máy bạn**: nút mới trên thẻ mỗi bot, bật lên là bot mở được Chrome hoặc app trong danh sách cho phép (TeamViewer, Zalo, …) trên máy đang chạy bot. **Mặc định TẮT**, cần token, chỉ giới hạn danh sách bạn khai, chỉ dùng trên máy có màn hình.
43
43
  - 🌐 **Duyệt web chạy ngay trên mọi hệ điều hành**: máy để bàn thì bot dùng **Chrome thật** (profile đã đăng nhập, web ít nghi là bot), server thì tự mở Chromium headless — hết cảnh "không có trình duyệt" chỉ vì chưa mở Chrome.
@@ -15,7 +15,7 @@ function loadSharedModule(modulePath, globalName) {
15
15
  }
16
16
  const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSkillMd, buildInfographicGeneratorJs } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
17
17
  const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
18
- const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
18
+ const { buildDockerArtifacts, contextDefaultsScript } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
19
19
  const { HOST_UI_PS1, HOST_UI_PS1_VERSION } = loadSharedModule('../setup/shared/host-ui-ps1.js', '__openclawHostUiPs1');
20
20
  const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, ZALO_CHANNEL_ID, ZALO_PLUGIN_ID, ZALO_CONNECT_VERSION, ZALO_CONNECT_PLUGIN_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
21
21
  const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
@@ -646,18 +646,73 @@ function getRealHomedir() {
646
646
  return home;
647
647
  }
648
648
 
649
+ /**
650
+ * Directories to search when a bare command is not sitting next to the running node.
651
+ *
652
+ * resolveBinPath's original assumption — "the CLI lives beside the node that runs me" — holds only
653
+ * while the Setup UI is launched by the same node that installed openclaw globally. It breaks the
654
+ * moment the UI runs under the SYSTEM node while openclaw lives in an nvm prefix: `openclaw` stays a
655
+ * bare name, spawn's own PATH lookup finds nothing either, and every skill/plugin install dies with
656
+ * `spawn openclaw ENOENT`. Hit on a customer VPS 2026-08-28, right after the UI became a systemd
657
+ * unit whose PATH carried only the system directories — `openclaw` existed the whole time, at
658
+ * /root/.nvm/versions/node/v24.20.0/bin/openclaw.
659
+ *
660
+ * PATH first (so an operator's own choice still wins), then nvm's per-version bins newest first,
661
+ * then the usual global npm prefixes. Cached: this runs on every spawn.
662
+ */
663
+ let extraBinDirsCache = null;
664
+ function extraBinDirs() {
665
+ if (extraBinDirsCache) return extraBinDirsCache;
666
+ const dirs = String(process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
667
+ const home = os.homedir();
668
+ const nvmRoot = process.env.NVM_DIR ? join(process.env.NVM_DIR, 'versions', 'node') : join(home, '.nvm', 'versions', 'node');
669
+ // Newest version first: a box with several nvm nodes almost always installed the CLI under the
670
+ // latest one, and picking the oldest would run openclaw on a node its engines field rejects.
671
+ try {
672
+ for (const v of fs.readdirSync(nvmRoot).sort().reverse()) dirs.push(join(nvmRoot, v, 'bin'));
673
+ } catch (e) {}
674
+ for (const d of ['/usr/local/bin', '/usr/bin', '/opt/homebrew/bin', join(home, '.npm-global', 'bin'), join(home, '.local', 'bin')]) dirs.push(d);
675
+ extraBinDirsCache = dirs;
676
+ return dirs;
677
+ }
678
+
649
679
  function resolveBinPath(cmd) {
650
680
  if (!cmd || cmd.includes('/') || cmd.includes('\\')) return cmd;
681
+ const names = process.platform === 'win32' ? [`${cmd}.cmd`, `${cmd}.exe`, cmd] : [cmd];
651
682
  const nodeBinDir = dirname(process.argv[0]);
652
- const localPath = join(nodeBinDir, process.platform === 'win32' ? `${cmd}.cmd` : cmd);
653
- if (existsSync(localPath)) return localPath;
654
- const localExe = join(nodeBinDir, process.platform === 'win32' ? `${cmd}.exe` : cmd);
655
- if (existsSync(localExe)) return localExe;
683
+ for (const name of names) {
684
+ const p = join(nodeBinDir, name);
685
+ if (existsSync(p)) return p;
686
+ }
656
687
  const nodeModulesBin = join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? `${cmd}.cmd` : cmd);
657
688
  if (existsSync(nodeModulesBin)) return nodeModulesBin;
689
+ for (const dir of extraBinDirs()) {
690
+ for (const name of names) {
691
+ const p = join(dir, name);
692
+ if (existsSync(p)) return p;
693
+ }
694
+ }
658
695
  return cmd;
659
696
  }
660
697
 
698
+ /**
699
+ * Child env for a resolved binary, with the binary's own directory on PATH.
700
+ *
701
+ * Load-bearing for anything picked out of an nvm prefix: `openclaw` there is a shebang script
702
+ * (`#!/usr/bin/env node`), so running it by absolute path still resolves `node` from the CHILD's
703
+ * PATH. Without this the CLI is found and then executed by whatever node happens to be first —
704
+ * the system one — which is not the node it was installed for.
705
+ */
706
+ function binEnv(bin, extra = {}) {
707
+ const env = { ...process.env, ...extra };
708
+ if (!bin.includes('/') && !bin.includes('\\')) return env;
709
+ const sep = process.platform === 'win32' ? ';' : ':';
710
+ const dir = dirname(bin);
711
+ const cur = String(env.PATH || '').split(sep).filter(Boolean);
712
+ if (!cur.includes(dir)) env.PATH = [dir, ...cur].join(sep);
713
+ return env;
714
+ }
715
+
661
716
  // Blacklist of Windows system/large directories that should never be walked
662
717
  const SYSTEM_DIR_BLACKLIST = new Set([
663
718
  'windows', 'program files', 'program files (x86)', 'programdata',
@@ -704,7 +759,7 @@ function run(cmd, args, opts = {}) {
704
759
  const shell = process.platform === 'win32';
705
760
  const rawBin = resolveBinPath(cmd);
706
761
  const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
707
- const child = spawn(bin, args, { cwd: opts.cwd, shell, env: { ...process.env, ...(opts.env || {}) } });
762
+ const child = spawn(bin, args, { cwd: opts.cwd, shell, env: binEnv(rawBin, opts.env) });
708
763
  let stdout = '';
709
764
  let resolved = false;
710
765
  child.stdout.on('data', (d) => {
@@ -877,7 +932,7 @@ function runStreamed(cmd, args, opts = {}) {
877
932
  cwd: opts.cwd,
878
933
  shell,
879
934
  windowsHide: opts.windowsHide ?? true,
880
- env: { ...process.env, ...(opts.env || {}) },
935
+ env: binEnv(rawBin, opts.env),
881
936
  });
882
937
  child.stdout.on('data', (d) => sendLog(String(d).trimEnd()));
883
938
  child.stderr.on('data', (d) => sendLog(String(d).trimEnd()));
@@ -895,7 +950,7 @@ function runStreamedToLogFile(cmd, args, logFile, opts = {}) {
895
950
  cwd: opts.cwd,
896
951
  shell,
897
952
  windowsHide: opts.windowsHide ?? true,
898
- env: { ...process.env, ...(opts.env || {}) },
953
+ env: binEnv(rawBin, opts.env),
899
954
  });
900
955
  let offset = 0;
901
956
  const poll = setInterval(async () => {
@@ -923,7 +978,7 @@ function runCapture(cmd, args, opts = {}) {
923
978
  cwd: opts.cwd,
924
979
  shell,
925
980
  windowsHide: opts.windowsHide ?? true,
926
- env: { ...process.env, ...(opts.env || {}) },
981
+ env: binEnv(rawBin, opts.env),
927
982
  });
928
983
  // Some callers need to feed stdin (pbcopy/xclip take the clipboard text that way).
929
984
  if (opts.input != null) {
@@ -1605,6 +1660,26 @@ function validateOpenclawConfig(cfg) {
1605
1660
  }
1606
1661
  if (!cfg.channels || typeof cfg.channels !== 'object') throw httpError(500, 'openclaw.json missing channels');
1607
1662
 
1663
+ // Self-healing: two zalo-connect bindings can never share one account — one Zalo number, one
1664
+ // listener. An older edit path hardcoded `accountId: 'default'`, so an edited bot could end up
1665
+ // squatting the first bot's account while its own account sat unbound (no listener, and the
1666
+ // other bot answered in its place). Only repair where the intent is unambiguous: the agent
1667
+ // already has an account of its own provisioned under its agentId.
1668
+ const zaloAccounts = cfg.channels?.['zalo-connect']?.accounts;
1669
+ if (zaloAccounts) {
1670
+ const owner = new Map();
1671
+ for (const binding of cfg.bindings || []) {
1672
+ if (binding.match?.channel !== 'zalo-connect') continue;
1673
+ const accountId = binding.match?.accountId || 'default';
1674
+ const agentId = binding.agentId;
1675
+ if (!owner.has(accountId)) { owner.set(accountId, agentId); continue; }
1676
+ if (owner.get(accountId) === agentId || !zaloAccounts[agentId]) continue;
1677
+ binding.match.accountId = agentId;
1678
+ owner.set(agentId, agentId);
1679
+ sendLog(`[config] Bot "${agentId}" đang dùng chung account Zalo "${accountId}" với "${owner.get(accountId)}" — đã trả về account riêng "${agentId}".`);
1680
+ }
1681
+ }
1682
+
1608
1683
  // Self-healing: Garbage collect any orphaned telegram accounts that are no longer bound to any active agent
1609
1684
  if (cfg.channels?.telegram?.accounts) {
1610
1685
  const boundAccountIds = new Set(
@@ -2012,9 +2087,15 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
2012
2087
  agent.workspace = `/home/node/project/.openclaw/${workspaceDir}`;
2013
2088
  agent.agentDir = `agents/${agentId}/agent`;
2014
2089
 
2015
- // Find the existing accountId from bindings BEFORE removing them
2016
- const existingBinding = (cfg.bindings || []).find((b) => b.agentId === agentId && b.match?.channel === 'telegram');
2017
- const existingAccountId = existingBinding?.match?.accountId || null;
2090
+ // Capture this agent's current accountId per channel BEFORE its bindings are dropped.
2091
+ // The zalo-connect branch below used to hardcode 'default' (and this lookup only covered
2092
+ // telegram), so editing ANY Zalo bot re-pointed it at the FIRST bot's Zalo account: two
2093
+ // bindings claimed `default`, the earlier one won, and the edited bot's own account was left
2094
+ // with no binding at all — so its listener never started and messages were answered by the
2095
+ // other bot. Seen live on a 5-bot install (bot answered under the first bot's name).
2096
+ const previousBindings = (cfg.bindings || []).filter((b) => b.agentId === agentId);
2097
+ const previousAccountId = (ch) => previousBindings.find((b) => b.match?.channel === ch)?.match?.accountId || null;
2098
+ const existingAccountId = previousAccountId('telegram');
2018
2099
  cfg.bindings = (cfg.bindings || []).filter((b) => b.agentId !== agentId);
2019
2100
  if (channel === 'telegram') {
2020
2101
  ensureTelegramChannel(cfg);
@@ -2030,7 +2111,16 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
2030
2111
  cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
2031
2112
  } else if (channel === 'zalo-personal') {
2032
2113
  ensureZaloConnectChannel(cfg);
2033
- cfg.bindings.push({ agentId, match: { channel: 'zalo-connect', accountId: 'default' } });
2114
+ const zc = cfg.channels['zalo-connect'];
2115
+ zc.accounts = zc.accounts || {};
2116
+ // Keep the account this bot already owns; only a bot that never had one falls back to the
2117
+ // create-path rule (first Zalo bot takes `default`, the rest get their own agentId account).
2118
+ // This agent's bindings are already filtered out above, so what remains belongs to others.
2119
+ const otherZaloBindings = cfg.bindings.filter((b) => b.match?.channel === 'zalo-connect').length;
2120
+ const accountId = previousAccountId('zalo-connect') || (otherZaloBindings === 0 ? 'default' : agentId);
2121
+ zc.accounts[accountId] = zc.accounts[accountId] || { enabled: true };
2122
+ zc.defaultAccount = zc.defaultAccount || 'default';
2123
+ cfg.bindings.push({ agentId, match: { channel: 'zalo-connect', accountId } });
2034
2124
  } else if (channel === 'fb-messenger') {
2035
2125
  ensureFbMessengerChannel(cfg, String(body.pageId || '').trim(), String(body.appId || '').trim());
2036
2126
  cfg.bindings.push({ agentId, match: { channel: 'fb-messenger', accountId: 'default' } });
@@ -2640,6 +2730,11 @@ const NATIVE_MARKER = 'native.json';
2640
2730
  // handles coexistence by asking the host what is actually taken, which the fixed offset never did.
2641
2731
  const NATIVE_DEFAULT_GATEWAY_PORT = 18789;
2642
2732
  const NATIVE_DEFAULT_ROUTER_PORT = 20128;
2733
+ // The gateway's startup migrations hold a lease on the state directory for FIVE MINUTES, and a boot
2734
+ // that collides with it exits immediately instead of waiting. Any health wait shorter than that
2735
+ // lease reports failure on a gateway that was always going to come up on its own — the old 180s
2736
+ // could not outlast it even in the best case.
2737
+ const NATIVE_GATEWAY_HEALTH_TIMEOUT_MS = 420000;
2643
2738
 
2644
2739
  function nativeMarkerPath(projectDir) {
2645
2740
  return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
@@ -2717,13 +2812,22 @@ async function waitForNativeGatewayHealthy(projectDir, timeoutMs = 120000) {
2717
2812
  const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2718
2813
  const started = Date.now();
2719
2814
  let attempts = 0;
2815
+ let diagnosed = false;
2720
2816
  while (Date.now() - started < timeoutMs) {
2721
2817
  if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) return true;
2722
2818
  attempts++;
2723
2819
  if (attempts % 5 === 0) sendLog(`[native] Waiting for gateway on ${port}... (${Math.round((Date.now() - started) / 1000)}s)`);
2820
+ // Half a minute of silence is already abnormal for a gateway that is merely slow, and the two
2821
+ // causes that never resolve on their own (a stolen port, an abandoned unit) look exactly like
2822
+ // "still booting" from here. Say what is wrong once, early, and keep waiting either way.
2823
+ if (attempts === 10 && !diagnosed) {
2824
+ diagnosed = true;
2825
+ await reportNativeGatewayBlockage(projectDir, port).catch(() => {});
2826
+ }
2724
2827
  await new Promise((r) => setTimeout(r, 3000));
2725
2828
  }
2726
2829
  sendLog(`[native] Gateway did not answer /health on ${port} within ${Math.round(timeoutMs / 1000)}s.`);
2830
+ if (!diagnosed) await reportNativeGatewayBlockage(projectDir, port).catch(() => {});
2727
2831
  return false;
2728
2832
  }
2729
2833
 
@@ -2766,7 +2870,33 @@ async function ocDaemon(projectDir, verb, extraArgs = []) {
2766
2870
  * perfectly healthy gateway reports "restart failed" — which used to send callers down a pointless
2767
2871
  * stop+start that raced the migration lease all over again.
2768
2872
  */
2873
+ /**
2874
+ * Docker projects replay the idempotent config migrations on every container start — the
2875
+ * entrypoint embeds contextDefaultsScript. Native projects have no entrypoint, so an existing
2876
+ * native bot never received those fixes (a bot created before 5.16.0 kept the smart-route
2877
+ * contextWindow at 200000 and stayed exposed to the compaction deadlock). Replaying the exact
2878
+ * same script here, right before every gateway (re)start, gives native the docker semantics:
2879
+ * migrate first, then boot on the upgraded config.
2880
+ */
2881
+ async function runNativeConfigMigrations(projectDir) {
2882
+ const res = await runCapture(process.execPath, ['-e', contextDefaultsScript], {
2883
+ cwd: projectDir,
2884
+ env: nativeEnv(projectDir),
2885
+ shell: false,
2886
+ });
2887
+ if (res.code !== 0) sendLog(`[native] config migrations skipped: ${String(res.stderr || res.stdout || '').trim().slice(0, 200)}`);
2888
+ return res.code === 0;
2889
+ }
2890
+
2769
2891
  async function restartNativeRuntime(projectDir) {
2892
+ // Every restart is a chance to repair a project installed before these fixes existed — the
2893
+ // calls are no-ops once the service env is complete, stray files are adopted, and the config
2894
+ // already carries the migrated defaults.
2895
+ await adoptStrayNativeHome(projectDir).catch(() => {});
2896
+ await syncNativeServiceEnv(projectDir).catch(() => {});
2897
+ await runNativeConfigMigrations(projectDir).catch(() => {});
2898
+ await hardenNativeServiceRestarts(projectDir).catch(() => {});
2899
+ await clearNativeServiceFailure(projectDir).catch(() => {});
2770
2900
  const stopStart = async () => {
2771
2901
  await ocDaemon(projectDir, 'stop');
2772
2902
  return ocDaemon(projectDir, 'start');
@@ -2791,14 +2921,111 @@ async function restartNativeRuntime(projectDir) {
2791
2921
  res = await ocDaemon(projectDir, 'restart');
2792
2922
  if (res.code !== 0) res = await stopStart();
2793
2923
  }
2794
- // systemd keeps restarting a crash-looping unit every RestartSec, so a gateway blocked by a lease
2795
- // we never saw still comes up on its own give it room before calling the restart a failure.
2796
- if (!(await waitForNativeGatewayHealthy(projectDir, 180000))) {
2924
+ // With the start limit lifted (hardenNativeServiceRestarts) systemd really does keep restarting a
2925
+ // crash-looping unit every RestartSec, so a gateway blocked by a lease we never saw still comes up
2926
+ // on its own — give it the full lease window before calling the restart a failure.
2927
+ if (!(await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS))) {
2797
2928
  throw new Error('gateway did not answer /health after restart');
2798
2929
  }
2799
2930
  return true;
2800
2931
  }
2801
2932
 
2933
+ /**
2934
+ * Make the generated service carry everything nativeEnv() promises.
2935
+ *
2936
+ * `openclaw daemon install` propagates only a fixed allow-list into the service it writes:
2937
+ * OPENCLAW_STATE_DIR survives, but OPENCLAW_HOME does NOT — verified on both a systemd user unit and
2938
+ * a launchd env-wrapper. Anything resolving paths from OPENCLAW_HOME then falls back to `~/.openclaw`
2939
+ * and writes OUTSIDE the project. zalo-connect is the visible casualty: it stages inbound files and
2940
+ * its Zalo session credentials under the wrong home, so a PDF sent to the bot lands somewhere the
2941
+ * agent's workspace cannot reach ("em chưa trích xuất được nội dung từ PDF") and the session sits in
2942
+ * a different home from the config that describes it.
2943
+ *
2944
+ * Idempotent: keys already present are left alone, so this is safe to run on every restart and it
2945
+ * self-heals projects created before the fix.
2946
+ */
2947
+ async function syncNativeServiceEnv(projectDir) {
2948
+ if (!isNativeProject(projectDir)) return [];
2949
+ const want = nativeEnv(projectDir);
2950
+ const label = nativeServiceLabel(projectDir);
2951
+
2952
+ if (process.platform === 'linux') {
2953
+ const unit = `${label}.service`;
2954
+ const shown = await runCapture('systemctl', ['--user', 'show', '-p', 'FragmentPath', '--value', unit], { shell: false, timeout: 10000 });
2955
+ const path = String(shown.stdout || '').trim() || join(os.homedir(), '.config', 'systemd', 'user', unit);
2956
+ if (!existsSync(path)) return [];
2957
+ const lines = (await fsp.readFile(path, 'utf8')).split('\n');
2958
+ const have = new Set();
2959
+ for (const line of lines) {
2960
+ const m = line.trim().match(/^Environment="?([A-Z_0-9]+)=/);
2961
+ if (m) have.add(m[1]);
2962
+ }
2963
+ const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
2964
+ if (!missing.length) return [];
2965
+ // Insert after the last existing Environment= line so the additions stay inside [Service].
2966
+ let at = -1;
2967
+ lines.forEach((line, i) => { if (line.startsWith('Environment=')) at = i; });
2968
+ if (at < 0) at = lines.findIndex((line) => line.trim() === '[Service]');
2969
+ if (at < 0) return [];
2970
+ lines.splice(at + 1, 0, ...missing.map(([k, v]) => `Environment=${k}=${v}`));
2971
+ await fsp.copyFile(path, `${path}.bak`).catch(() => {});
2972
+ await fsp.writeFile(path, lines.join('\n'), 'utf8');
2973
+ await run('systemctl', ['--user', 'daemon-reload'], {}).catch(() => {});
2974
+ sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
2975
+ return missing.map(([k]) => k);
2976
+ }
2977
+
2978
+ if (process.platform === 'darwin') {
2979
+ // launchd runs the gateway through an env-wrapper that sources this file, so patching it is the
2980
+ // launchd equivalent of adding Environment= lines to a unit.
2981
+ const envFile = join(projectDir, '.openclaw', 'service-env', `${label}.env`);
2982
+ if (!existsSync(envFile)) return [];
2983
+ const body = await fsp.readFile(envFile, 'utf8');
2984
+ const have = new Set([...body.matchAll(/^\s*export\s+([A-Z_0-9]+)=/gm)].map((m) => m[1]));
2985
+ const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
2986
+ if (!missing.length) return [];
2987
+ const added = missing.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`).join('\n');
2988
+ await fsp.copyFile(envFile, `${envFile}.bak`).catch(() => {});
2989
+ await fsp.writeFile(envFile, `${body.replace(/\n*$/, '')}\n${added}\n`, 'utf8');
2990
+ sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
2991
+ return missing.map(([k]) => k);
2992
+ }
2993
+
2994
+ return [];
2995
+ }
2996
+
2997
+ /**
2998
+ * Reunite a native project with the files an unset OPENCLAW_HOME scattered into `~/.openclaw`.
2999
+ *
3000
+ * This MUST run before syncNativeServiceEnv takes effect: once OPENCLAW_HOME is finally correct, the
3001
+ * plugin looks for its Zalo session inside the project — and if the credentials are still sitting in
3002
+ * the home directory it finds nothing and demands a fresh QR login. Copy (never move) so a failed
3003
+ * run leaves the working original in place; skip anything the project already has.
3004
+ */
3005
+ async function adoptStrayNativeHome(projectDir) {
3006
+ if (!isNativeProject(projectDir)) return [];
3007
+ const projectHome = join(projectDir, '.openclaw');
3008
+ const strayHome = join(os.homedir(), '.openclaw');
3009
+ if (resolve(strayHome) === resolve(projectHome) || !existsSync(strayHome)) return [];
3010
+ const moved = [];
3011
+ const entries = await fsp.readdir(strayHome, { withFileTypes: true }).catch(() => []);
3012
+ for (const entry of entries) {
3013
+ // `state` is deliberately excluded: the project has its own live database and merging two
3014
+ // sqlite files is not something a copy can do correctly.
3015
+ const isCreds = /^zalo-connect-credentials.*\.json$/.test(entry.name);
3016
+ if (!isCreds && entry.name !== 'media') continue;
3017
+ const from = join(strayHome, entry.name);
3018
+ const to = join(projectHome, entry.name);
3019
+ if (existsSync(to)) continue;
3020
+ await fsp.cp(from, to, { recursive: true }).catch(() => {});
3021
+ if (existsSync(to)) moved.push(entry.name);
3022
+ }
3023
+ if (moved.length) {
3024
+ sendLog(`[migrate] Native: adopted ${moved.join(', ')} from ${strayHome} (written there while OPENCLAW_HOME was unset).`);
3025
+ }
3026
+ return moved;
3027
+ }
3028
+
2802
3029
  /**
2803
3030
  * `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
2804
3031
  * unit — and a user manager without linger is torn down when that user's last session exits. On a
@@ -2824,6 +3051,112 @@ async function ensureSystemdLinger() {
2824
3051
  return false;
2825
3052
  }
2826
3053
 
3054
+ /**
3055
+ * Stop systemd abandoning the gateway while the state lease is still held.
3056
+ *
3057
+ * `openclaw daemon install` writes `StartLimitBurst=5` / `StartLimitIntervalSec=60` next to
3058
+ * `Restart=always`. The gateway's startup migrations take a lease on the state directory that lasts
3059
+ * five minutes, and a boot that collides with it exits 1 *immediately* — so five collisions burn the
3060
+ * whole limit in 30 seconds, systemd logs "Start request repeated too quickly", and the unit stays
3061
+ * dead for good even though the lease frees itself minutes later. Verified on a fresh Ubuntu 24.04
3062
+ * VPS (2026-08-28): the setup UI sat on "Waiting for gateway on 18789..." until it timed out, while
3063
+ * `systemctl --user start` afterwards brought the same unit up on the first try.
3064
+ *
3065
+ * A drop-in rather than an edit to the unit body: `daemon install --force` rewrites the unit on every
3066
+ * run, and drop-ins survive that.
3067
+ */
3068
+ async function hardenNativeServiceRestarts(projectDir) {
3069
+ if (process.platform !== 'linux' || !isNativeProject(projectDir)) return false;
3070
+ const unit = `${nativeServiceLabel(projectDir)}.service`;
3071
+ const dir = join(os.homedir(), '.config', 'systemd', 'user', `${unit}.d`);
3072
+ const file = join(dir, '99-openclaw-setup.conf');
3073
+ const body = [
3074
+ '# Written by openclaw-setup — regenerated on every install, do not edit.',
3075
+ '# Startup migrations hold the state lease for ~5 minutes and a colliding boot exits at once, so',
3076
+ '# the stock StartLimitBurst=5/StartLimitIntervalSec=60 abandons the unit for good within 30s.',
3077
+ '# No start limit = systemd keeps retrying every RestartSec until the lease frees itself.',
3078
+ '[Unit]',
3079
+ 'StartLimitIntervalSec=0',
3080
+ '[Service]',
3081
+ 'RestartSec=10',
3082
+ '',
3083
+ ].join('\n');
3084
+ if ((await fsp.readFile(file, 'utf8').catch(() => '')) === body) return true;
3085
+ await fsp.mkdir(dir, { recursive: true });
3086
+ await fsp.writeFile(file, body, 'utf8');
3087
+ await run('systemctl', ['--user', 'daemon-reload'], {}).catch(() => {});
3088
+ sendLog('[native] service hardened: systemd start limit lifted (a migration lease can take 5 min).');
3089
+ return true;
3090
+ }
3091
+
3092
+ /**
3093
+ * A unit parked at "Start request repeated too quickly" refuses every later `start` until its
3094
+ * failure is cleared — including the one that would finally succeed. Cheap and idempotent, so it
3095
+ * runs before each start and also repairs a project abandoned by an earlier install.
3096
+ */
3097
+ async function clearNativeServiceFailure(projectDir) {
3098
+ if (process.platform !== 'linux' || !isNativeProject(projectDir)) return false;
3099
+ const unit = `${nativeServiceLabel(projectDir)}.service`;
3100
+ const shown = await runCapture('systemctl', ['--user', 'show', '-p', 'ActiveState', '--value', unit], { shell: false, timeout: 10000 });
3101
+ if (!/^failed/i.test(String(shown.stdout || '').trim())) return false;
3102
+ await run('systemctl', ['--user', 'reset-failed', unit], {}).catch(() => {});
3103
+ sendLog(`[native] cleared the previous failed state of ${unit} before starting it.`);
3104
+ return true;
3105
+ }
3106
+
3107
+ /**
3108
+ * Name whatever already owns a port, so a stalled wait can say why it will never end.
3109
+ *
3110
+ * The trap this exists for: an operator runs the port-forward command out of `ssh <host>-setup`
3111
+ * (`ssh -L 18789:127.0.0.1:18789 root@<host>`) while already logged INTO that host instead of from
3112
+ * their own machine. ssh binds 18789 locally and forwards it to 18789 on the same box — a self-loop
3113
+ * that accepts every connection and answers nothing. The gateway can then never bind its port, and
3114
+ * health probes hang instead of failing, so the port looks alive and is useless. Seen on a customer
3115
+ * VPS 2026-08-28. `ss` (iproute2) is used on Linux rather than `lsof`, which a minimal Ubuntu lacks.
3116
+ */
3117
+ async function describePortHolder(port) {
3118
+ const trim = (v) => String(v || '').trim();
3119
+ if (process.platform === 'win32') {
3120
+ const out = await runCapture('powershell', ['-NoProfile', '-Command', `$p=(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess; if ($p) { "pid $p: " + (Get-Process -Id $p -ErrorAction SilentlyContinue).Path }`], { shell: false, timeout: 15000 });
3121
+ return trim(out.stdout);
3122
+ }
3123
+ if (process.platform === 'linux') {
3124
+ const ss = await runCapture('ss', ['-ltnp'], { shell: false, timeout: 10000 });
3125
+ const line = String(ss.stdout || '').split('\n').find((l) => new RegExp(`[:.]${port}\\s`).test(l)) || '';
3126
+ const pid = (line.match(/pid=(\d+)/) || [])[1];
3127
+ if (!pid) return trim(line);
3128
+ const ps = await runCapture('ps', ['-o', 'args=', '-p', pid], { shell: false, timeout: 10000 });
3129
+ return `pid ${pid}: ${trim(ps.stdout) || trim(line)}`;
3130
+ }
3131
+ const out = await runCapture('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], { shell: false, timeout: 10000 });
3132
+ return trim(String(out.stdout || '').split('\n')[1]);
3133
+ }
3134
+
3135
+ /**
3136
+ * Explain a gateway that is not answering, instead of counting down in silence.
3137
+ *
3138
+ * Three causes, all indistinguishable from "still booting" without this: an ssh port-forward
3139
+ * self-loop holding the port (describePortHolder), the migration lease plus systemd's start limit
3140
+ * leaving the unit dead for good (hardenNativeServiceRestarts), or an ordinary crash on boot — for
3141
+ * which the unit's own journal is the only thing that ever says so.
3142
+ */
3143
+ async function reportNativeGatewayBlockage(projectDir, port) {
3144
+ const holder = await describePortHolder(port).catch(() => '');
3145
+ if (holder) {
3146
+ sendLog(`[native] Port ${port} is already held by → ${holder}`);
3147
+ if (/(^|\/|\s)ssh(\s|$)/.test(holder) && new RegExp(`-L\\s*\\d*:?${port}:`).test(holder)) {
3148
+ sendLog(`[native] That is an SSH port-forward, not the gateway. \`ssh -L ${port}:...\` belongs on YOUR OWN machine — run it on the server and it steals port ${port} from the gateway and answers nothing. Kill that ssh process on the server, then start the gateway again.`);
3149
+ }
3150
+ }
3151
+ if (process.platform !== 'linux' || !isNativeProject(projectDir)) return;
3152
+ const unit = `${nativeServiceLabel(projectDir)}.service`;
3153
+ const st = await runCapture('systemctl', ['--user', 'show', '-p', 'ActiveState', '-p', 'SubState', '-p', 'NRestarts', '--value', unit], { shell: false, timeout: 10000 }).catch(() => ({}));
3154
+ const vals = String(st.stdout || '').split('\n').map((v) => v.trim()).filter(Boolean);
3155
+ if (vals.length) sendLog(`[native] ${unit} → ${vals.join(' / ')}`);
3156
+ const log = await runCapture('journalctl', ['--user', '-u', unit, '-n', '15', '--no-pager'], { shell: false, timeout: 15000 }).catch(() => ({}));
3157
+ for (const line of String(log.stdout || '').split('\n').map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
3158
+ }
3159
+
2827
3160
  /**
2828
3161
  * Native counterpart of the docker entrypoint's `ensure_plugin` (docker-gen.js).
2829
3162
  *
@@ -2836,6 +3169,16 @@ async function ensureSystemdLinger() {
2836
3169
  */
2837
3170
  async function ensureNativePlugins(projectDir, { restart = false } = {}) {
2838
3171
  if (!isNativeProject(projectDir)) return [];
3172
+ // Same cleanup the container entrypoint does (docker-gen.js): an interrupted `plugins install`
3173
+ // leaves extensions/.openclaw-install-stage-XXXXXX behind, and it still carries a plugin manifest —
3174
+ // so the gateway logs "duplicate plugin id detected" every boot and a stale build competes with the
3175
+ // real one for the same id. Native has no entrypoint, so it has to happen here.
3176
+ const extRoot = join(projectDir, '.openclaw', 'extensions');
3177
+ for (const entry of await fsp.readdir(extRoot, { withFileTypes: true }).catch(() => [])) {
3178
+ if (!entry.isDirectory() || !entry.name.startsWith('.openclaw-install-stage-')) continue;
3179
+ await fsp.rm(join(extRoot, entry.name), { recursive: true, force: true }).catch(() => {});
3180
+ sendLog(`[native] removed abandoned plugin staging dir ${entry.name}`);
3181
+ }
2839
3182
  let cfg = {};
2840
3183
  try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
2841
3184
  // learning-memory backs plugins.slots.contextEngine for every bot; zalo-connect only when a bot
@@ -2873,7 +3216,7 @@ function startDetached(cmd, args, opts = {}) {
2873
3216
  detached: true,
2874
3217
  stdio: 'ignore',
2875
3218
  windowsHide: opts.windowsHide ?? true,
2876
- env: { ...process.env, ...(opts.env || {}) },
3219
+ env: binEnv(rawBin, opts.env),
2877
3220
  });
2878
3221
  child.on('error', (err) => sendLog(`[native] Failed to start "${cmd}": ${err.message}`));
2879
3222
  child.unref();
@@ -2967,15 +3310,38 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
2967
3310
  // straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
2968
3311
  await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
2969
3312
 
3313
+ // Config migrations BEFORE the first boot too — same ordering the container entrypoint uses
3314
+ // (migrate, then start), so an adopted pre-existing project boots on the upgraded config.
3315
+ await runNativeConfigMigrations(projectDir).catch(() => {});
3316
+
2970
3317
  // Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
2971
3318
  // equivalent of docker's `restart: always`. --force so re-running install updates the port.
2972
3319
  const env = nativeEnv(projectDir);
2973
3320
  await ensureSystemdLinger();
2974
3321
  await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
2975
- await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2976
- // Let the first boot finish its state migrations here, while nothing else is competing for the
2977
- // lease. Every later action (create bot, install plugin) then restarts a settled gateway.
2978
- await waitForNativeGatewayHealthy(projectDir, 180000);
3322
+ await hardenNativeServiceRestarts(projectDir).catch((e) => sendLog(`[native] service hardening skipped: ${e.message}`));
3323
+ await clearNativeServiceFailure(projectDir).catch(() => {});
3324
+ // `daemon install` already STARTED the unit, and that first boot begins the state migrations that
3325
+ // hold a five-minute lease on the state directory. This used to restart it right away — to pick up
3326
+ // the env completed below — and the restart landed mid-migration: the lease was left behind, every
3327
+ // following boot exited 1 until it expired, and systemd's start limit abandoned the unit long
3328
+ // before that. The setup UI then waited out its whole timeout on a service that was never coming
3329
+ // back (real Ubuntu 24.04 VPS, 2026-08-28). So let this boot finish UNDISTURBED first, and only
3330
+ // then repair the env and restart a settled gateway.
3331
+ const firstBootOk = await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS);
3332
+ // Order is load-bearing: adopt the stray files FIRST, then complete the service env. The other way
3333
+ // round, the gateway boots with a corrected OPENCLAW_HOME, finds no Zalo session there, and asks
3334
+ // for a new QR scan even though a perfectly good session exists in the home directory.
3335
+ await adoptStrayNativeHome(projectDir).catch((e) => sendLog(`[migrate] stray home skipped: ${e.message}`));
3336
+ const envAdded = await syncNativeServiceEnv(projectDir).catch((e) => { sendLog(`[native] service env sync skipped: ${e.message}`); return []; });
3337
+ // One restart, and only when there is something to gain by it: a completed env to load, or a first
3338
+ // boot that never answered (in which case the restart is the repair attempt).
3339
+ if (envAdded.length || !firstBootOk) {
3340
+ sendLog(envAdded.length
3341
+ ? '[native] restarting gateway to load the completed service env'
3342
+ : '[native] first boot never answered /health — restarting once to recover');
3343
+ await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after install: ${e.message}`));
3344
+ }
2979
3345
  sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
2980
3346
  return { gatewayPort: gwPort, routerPort: rtPort, label };
2981
3347
  }
@@ -3128,7 +3494,7 @@ async function recreateDockerBot(projectDir) {
3128
3494
  // under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
3129
3495
  // abandon the unit. This is the same trap the docker path avoids by waiting for the container
3130
3496
  // before touching it (see startZaloConnectLogin) — wait for /health first.
3131
- await waitForNativeGatewayHealthy(projectDir, 180000);
3497
+ await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS);
3132
3498
  // The bot that was just created/edited may have added the Zalo channel or the context engine to
3133
3499
  // openclaw.json; put those plugins on disk now so this one reload loads them too.
3134
3500
  await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin ensure skipped: ${e.message}`));
@@ -6628,11 +6994,57 @@ async function printRemoteAccessHint(uiPort) {
6628
6994
  console.log(` ssh ${fwd} ${sshUserName()}@${ip}`);
6629
6995
  console.log(` then open: http://localhost:${uiPort}`);
6630
6996
  console.log(` (forwards ${ports.length} live port${ports.length > 1 ? 's' : ''}: ${ports.join(', ')})`);
6997
+ // Spelled out because the obvious mistake is to paste that line into the shell it was printed in.
6998
+ // Run there, ssh binds every one of those ports on THIS box and forwards them straight back to
6999
+ // itself: the gateway can no longer bind 18789, and the self-loop accepts connections while
7000
+ // answering nothing, so the port looks alive and the whole install hangs on "Waiting for gateway".
7001
+ // Cost a customer VPS install on 2026-08-28.
7002
+ console.log(' ⚠️ Run that line on YOUR OWN machine — NOT in this shell. On the server it');
7003
+ console.log(` steals port ${ports.join('/')} from the bot and the install never finishes.`);
6631
7004
  console.log('');
6632
7005
  }
6633
7006
 
7007
+ /** True when whatever answers on host:port already looks like an OpenClaw Setup UI. */
7008
+ async function detectExistingSetupUi(host, port) {
7009
+ try {
7010
+ const res = await fetch(`http://${host}:${port}/`, { signal: AbortSignal.timeout(2500) });
7011
+ if (!res.ok) return false;
7012
+ const body = (await res.text()).slice(0, 65536);
7013
+ return /openclaw/i.test(body);
7014
+ } catch (_) {
7015
+ return false;
7016
+ }
7017
+ }
7018
+
6634
7019
  export async function startLocalInstaller({ host = '127.0.0.1', preferredPort = 51789, openBrowser = true, projectDir = process.cwd() } = {}) {
6635
7020
  const port = await findPort(host, preferredPort);
7021
+ if (port !== preferredPort && (await detectExistingSetupUi(host, preferredPort))) {
7022
+ // Another Setup UI already owns the preferred port (a systemd service, an earlier npx run…).
7023
+ // Hopping to :51790 here is exactly how operators end up with SSH tunnels and printed hints
7024
+ // pointing at a port nothing serves — so reuse the running instance instead of starting a
7025
+ // second one, and keep this process alive so an `ssh -L … "npx create-openclaw-bot"`
7026
+ // one-liner still holds the tunnel open. If the other instance ever goes away, take the
7027
+ // port over so the URL keeps working without the operator re-running anything.
7028
+ const url = `http://${host}:${preferredPort}`;
7029
+ activeUiHost = host;
7030
+ activeUiPort = preferredPort;
7031
+ activeUiProjectDir = projectDir;
7032
+ console.log(`OpenClaw Setup UI is already running: ${url}`);
7033
+ console.log('Reusing the running instance — nothing new was started. Keep this window open if it holds your SSH tunnel.');
7034
+ ensureReopenShortcut();
7035
+ if (openBrowser) openUrl(url);
7036
+ printRemoteAccessHint(preferredPort).catch(() => {});
7037
+ const takeover = setInterval(async () => {
7038
+ if ((await findPort(host, preferredPort)) !== preferredPort) return; // still busy
7039
+ clearInterval(takeover);
7040
+ console.log(`Port ${preferredPort} freed up — starting a Setup UI there to keep ${url} working.`);
7041
+ startLocalInstaller({ host, preferredPort, openBrowser: false, projectDir }).catch(() => {});
7042
+ }, 5000);
7043
+ return;
7044
+ }
7045
+ if (port !== preferredPort) {
7046
+ console.log(`⚠ Port ${preferredPort} is busy with something that is not a Setup UI — using ${port} instead.`);
7047
+ }
6636
7048
  activeUiHost = host;
6637
7049
  activeUiPort = port;
6638
7050
  activeUiProjectDir = projectDir;
@@ -6658,4 +7070,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
6658
7070
  ]).catch(() => {});
6659
7071
  }
6660
7072
 
6661
- export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort };
7073
+ export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome, runNativeConfigMigrations, detectExistingSetupUi, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };
@@ -1,6 +1,11 @@
1
1
  // @ts-nocheck
2
2
  (function (root) {
3
- const OPENCLAW_NPM_SPEC = 'openclaw@latest';
3
+ // GHIM version, khong dung @latest: Dockerfile sinh ra mang spec nay, nen "@latest" nghia la
4
+ // MOI lan rebuild image co the nhay han mot the he openclaw — schema config doi, state DB doi
5
+ // migration, va bot khach chet ngay sau mot lan bam rebuild vo hai (ca that 103.98.149.154,
6
+ // 31/08/2026: rebuild keo 2026.7.1-2 → 2026.8.1, gateway tu choi boot, 66 lan restart).
7
+ // Nang version = doi spec nay MOT CACH CO Y trong mot ban phat hanh setup, kem kiem chung.
8
+ const OPENCLAW_NPM_SPEC = 'openclaw@2026.8.1';
4
9
  const OPENCLAW_RUNTIME_PACKAGES = 'grammy @grammyjs/runner @grammyjs/transformer-throttler @buape/carbon @larksuiteoapi/node-sdk @slack/web-api';
5
10
  const NINE_ROUTER_NPM_SPEC = '9router@latest';
6
11
  const NINE_ROUTER_PORT = 20128;
@@ -268,7 +273,14 @@ If setup reported a plugin install error, run this after the bot is running:
268
273
  {
269
274
  id: 'smart-route',
270
275
  name: 'Smart Proxy (Auto Route)',
271
- contextWindow: 200000,
276
+ // 1M (Kent chot 01/09/2026) — dao lai quyet dinh 131072 cua 5.16.0. Boi canh de doc
277
+ // lai sau: 131072 sinh ra vi smart-route fan-out sang upstream free ma model nho nhat
278
+ // trong pool tran ~128k; khai 200k lam phien day den muc chinh loi goi compaction cung
279
+ // overflow ("auto-compaction could not recover" cho den /new). Gio pool upstream da la
280
+ // cac model cua so lon nen khai 1M de phien dai khong bi compaction som. TRADE-OFF: neu
281
+ // mot combo van con model 128k thi phien vuot 128k se chet dung nhu hoi 5.15.x — do la
282
+ // van de cua combo, sua bang cach chon model, khong sua bang cach ha window o day nua.
283
+ contextWindow: 1048576,
272
284
  maxTokens: 8192,
273
285
  input: ['text', 'image'],
274
286
  },
@@ -194,6 +194,21 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
194
194
  return `node -e \\"const fs=require('fs'),os=require('os'),path=require('path'),p=path.join(process.cwd(),'.openclaw','openclaw.json');if(fs.existsSync(p)){const c=JSON.parse(fs.readFileSync(p,'utf8'));const gp=Number(process.env.OPENCLAW_GATEWAY_PORT||process.env.OPENCLAW_PORT)||c.gateway?.port||18789;const a=new Set(['http://localhost:'+gp,'http://127.0.0.1:'+gp,'http://0.0.0.0:'+gp]);for(const entries of Object.values(os.networkInterfaces()||{})){for(const entry of entries||[]){if(!entry||entry.internal||entry.family!=='IPv4'||!entry.address)continue;a.add('http://' + entry.address + ':'+gp);}}const p9=c.models&&c.models.providers&&c.models.providers['9router'];if(p9){p9.request=Object.assign({},p9.request,{allowPrivateNetwork:true});}c.tools=Object.assign({},c.tools,{profile:'full',exec:{host:'gateway',security:'full',ask:'off'}});c.gateway=Object.assign({},c.gateway,{port:gp,bind:'custom',customBindHost:'0.0.0.0',controlUi:Object.assign({},c.gateway?.controlUi,{allowedOrigins:Array.from(a).filter(Boolean)})});fs.writeFileSync(p,JSON.stringify(c,null,2));}\\"`;
195
195
  }
196
196
 
197
+ // Idempotent config upgrade replayed on every runtime start. Docker embeds it in the container
198
+ // entrypoint; the native runtime (local-server.js) runs it via `node -e` before every gateway
199
+ // (re)start — one script, both deploy modes, so native bots stop missing config fixes like the
200
+ // smart-route contextWindow 200000/131072 → 1048576 (Kent chot 01/09/2026; chi dung den DUNG
201
+ // hai gia tri setup tung ghi, custom tuning giu nguyen). Full rationale sits with the
202
+ // entrypoint block inside buildDockerArtifacts.
203
+ // ⚠️ toolResultMaxChars: openclaw ≥2026.8 BO key nay khoi schema va TU CHOI BOOT khi thay no.
204
+ // Ban cu cua script nay them key vo dieu kien moi lan container start — nen mot khach rebuild
205
+ // image (Dockerfile keo openclaw moi) la roi vao vong: gateway chet vi key ⇒ xoa tay ⇒ entrypoint
206
+ // them lai ⇒ chet tiep, khong lo ra thu pham (ca that 103.98.149.154, 31/08/2026, 66 lan restart).
207
+ // Gio gate theo version openclaw THAT trong container: <2026.8 thi backfill nhu cu, ≥2026.8 thi
208
+ // GO key neu con (tu chua cac project da nhiem). Khong doc duoc version thi KHONG dong nao chay
209
+ // — tha thieu mot default con hon gieo key lam gateway tu choi boot.
210
+ const contextDefaultsScript = `const fs=require('fs'),path=require('path');const p=path.join(process.cwd(),'.openclaw','openclaw.json');if(fs.existsSync(p)){const c=JSON.parse(fs.readFileSync(p,'utf8'));let ch=false;c.skills=c.skills||{};c.skills.workshop=c.skills.workshop||{};if(!c.skills.workshop.approvalPolicy){c.skills.workshop.approvalPolicy='auto';ch=true;}if(c.browser&&c.browser.enabled!==false){c.tools=c.tools||{};const dn=Array.isArray(c.tools.deny)?c.tools.deny:[];if(!dn.includes('browser')){dn.push('browser');c.tools.deny=dn;ch=true;}}const d=(c.agents&&c.agents.defaults)?c.agents.defaults:null;if(d){if(d.imageMaxDimensionPx===undefined){d.imageMaxDimensionPx=1024;ch=true;}if(d.imageQuality===undefined){d.imageQuality='efficient';ch=true;}let ocMajorMinor=0;try{const v=String(require('child_process').execSync('openclaw --version',{stdio:['ignore','pipe','ignore']})).match(/(\\d{4})\\.(\\d+)/);if(v)ocMajorMinor=Number(v[1])*100+Number(v[2]);}catch(e){}if(ocMajorMinor&&ocMajorMinor<202608){d.contextLimits=d.contextLimits||{};if(d.contextLimits.toolResultMaxChars===undefined){d.contextLimits.toolResultMaxChars=12000;ch=true;}}else if(d.contextLimits&&d.contextLimits.toolResultMaxChars!==undefined){delete d.contextLimits.toolResultMaxChars;ch=true;}}const pr=c.models&&c.models.providers&&c.models.providers['9router'];if(pr&&Array.isArray(pr.models)){for(const m of pr.models){if(m&&m.id==='smart-route'&&(m.contextWindow===200000||m.contextWindow===131072)){m.contextWindow=1048576;ch=true;}}}if(ch)fs.writeFileSync(p,JSON.stringify(c,null,2));}`;
211
+
197
212
  function buildDockerArtifacts(options) {
198
213
  const {
199
214
  openClawNpmSpec,
@@ -272,8 +287,13 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
272
287
  // • imageMaxDimensionPx / imageQuality / contextLimits.toolResultMaxChars → keep one
273
288
  // heavy turn (deep research, 4K chart read-back) from overflowing the context
274
289
  // window mid tool-loop, which cannot be compacted and poisons the session.
290
+ // • smart-route contextWindow 200000 → 131072: the old declared window exceeded the
291
+ // smallest upstream in typical free-model combos, so full sessions deadlocked —
292
+ // the compaction summarize call itself overflowed and only /new recovered. Only the
293
+ // exact setup-written 200000 is rewritten; an operator's custom value is left alone.
275
294
  // Each key is only filled in when absent, so an operator's own tuning is never clobbered.
276
- const contextDefaultsScript = `const fs=require('fs'),path=require('path');const p=path.join(process.cwd(),'.openclaw','openclaw.json');if(fs.existsSync(p)){const c=JSON.parse(fs.readFileSync(p,'utf8'));let ch=false;c.skills=c.skills||{};c.skills.workshop=c.skills.workshop||{};if(!c.skills.workshop.approvalPolicy){c.skills.workshop.approvalPolicy='auto';ch=true;}if(c.browser&&c.browser.enabled!==false){c.tools=c.tools||{};const dn=Array.isArray(c.tools.deny)?c.tools.deny:[];if(!dn.includes('browser')){dn.push('browser');c.tools.deny=dn;ch=true;}}const d=(c.agents&&c.agents.defaults)?c.agents.defaults:null;if(d){if(d.imageMaxDimensionPx===undefined){d.imageMaxDimensionPx=1024;ch=true;}if(d.imageQuality===undefined){d.imageQuality='efficient';ch=true;}d.contextLimits=d.contextLimits||{};if(d.contextLimits.toolResultMaxChars===undefined){d.contextLimits.toolResultMaxChars=12000;ch=true;}}if(ch)fs.writeFileSync(p,JSON.stringify(c,null,2));}`;
295
+ // (The script itself lives at module scope the native runtime replays the exact same
296
+ // migration before every gateway (re)start, since native projects have no entrypoint.)
277
297
  // Companion backfill for the same older projects: their TOOLS.md was generated before the
278
298
  // skill-authoring / long-turn guidance existed, and workspace files are only written when a
279
299
  // bot is created — so a rebuild alone leaves the assistant stopping at "proposal awaiting
@@ -288,6 +308,17 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
288
308
  'export OPENCLAW_HOME="${OPENCLAW_HOME:-$PWD/.openclaw}"',
289
309
  'export OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-$OPENCLAW_HOME}"',
290
310
  'mkdir -p "$OPENCLAW_HOME" "$OPENCLAW_STATE_DIR"',
311
+ // `openclaw plugins install` unpacks into extensions/.openclaw-install-stage-XXXXXX and removes
312
+ // it when it finishes. An interrupted install leaves the staging copy behind — and it still
313
+ // carries a plugin manifest, so the gateway logs "duplicate plugin id detected" on every boot
314
+ // and a stale build competes with the real one for the same id. Found on a production host: a
315
+ // zalo-connect 3.0.7 stage dir shadowing 3.0.17 for a week. Nothing is installing at entrypoint
316
+ // time, so any stage dir here is by definition abandoned.
317
+ 'for stage in "$OPENCLAW_HOME"/extensions/.openclaw-install-stage-*; do',
318
+ ' [ -d "$stage" ] || continue',
319
+ ' echo "[entrypoint] removing abandoned plugin staging dir $(basename "$stage")"',
320
+ ' rm -rf "$stage"',
321
+ 'done',
291
322
  'if [ "$OPENCLAW_STATE_DIR" != "$OPENCLAW_HOME" ]; then',
292
323
  ' for path in "$OPENCLAW_HOME"/*; do',
293
324
  ' [ -e "$path" ] || continue',
@@ -436,6 +467,24 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
436
467
  // `set -e` is on: a non-zero return here must never stop the gateway from starting.
437
468
  'start_local_headless_chrome || true',
438
469
  ].join('\n'));
470
+ // ── Doctor-on-upgrade ─────────────────────────────────────────────────────────────────
471
+ // Update openclaw khong bao gio chi la "doi so version": schema config doi (key cu bi TU CHOI
472
+ // chu khong bo qua) va state DB doi migration (audit-events-v2…), va doctor chi chay sach khi
473
+ // gateway DANG TAT — tuc dung ngay tai day, truoc `gateway run`, trong container vua boot.
474
+ // So version hien tai voi lan boot truoc (marker trong $OPENCLAW_HOME); khac nhau moi chay,
475
+ // hai luot vi doctor boc migration theo LOP (ca that 103.98.149.154 can nhieu luot). `yes |`
476
+ // vi doctor co cau hoi tuong tac; loi doctor khong chan boot — gateway se tu noi not phan con
477
+ // thieu, con hon container chet cung khong ai chan doan duoc.
478
+ runtimeParts.push([
479
+ 'OC_VER="$(openclaw --version 2>/dev/null | head -1)"',
480
+ 'OC_VER_MARKER="$OPENCLAW_HOME/.openclaw-last-version"',
481
+ 'if [ -n "$OC_VER" ] && [ "$OC_VER" != "$(cat "$OC_VER_MARKER" 2>/dev/null)" ]; then',
482
+ ' echo "[entrypoint] openclaw version changed ($(cat "$OC_VER_MARKER" 2>/dev/null || echo none) -> $OC_VER); running doctor --fix"',
483
+ ' yes | openclaw doctor --fix || true',
484
+ ' yes | openclaw doctor --fix || true',
485
+ ' printf %s "$OC_VER" > "$OC_VER_MARKER" || true',
486
+ 'fi',
487
+ ].join('\n'));
439
488
  runtimeParts.push('openclaw gateway run');
440
489
  const runtimeScript = ['#!/bin/sh', 'set -e', ...runtimeParts].join('\n');
441
490
  let browserInstall = '';
@@ -682,6 +731,7 @@ ${appEnvironmentBlock}${plainSingleExtraHosts ? `${extraHostsBlock}\n` : ''}
682
731
  build9RouterComposeEntrypointScript,
683
732
  buildGatewayPatchCmd,
684
733
  buildDockerArtifacts,
734
+ contextDefaultsScript,
685
735
  };
686
736
 
687
737
  })(typeof globalThis !== 'undefined' ? globalThis : {});
package/dist/web/app.js CHANGED
@@ -413,7 +413,11 @@ function remoteAccessPanel(s = {}) {
413
413
  const host = r.host || '<your-server-ip>';
414
414
  const user = r.user || 'root';
415
415
  const portOf = (raw, def) => { try { return new URL(raw).port || def; } catch { return def; } };
416
- const ports = Array.from(new Set([r.uiPort || 51789, portOf(s.gatewayUrl, 18789), portOf(s.routerUrl, 20128), 18790]));
416
+ // zalo-mod's dashboard is gateway port + 1, not a fixed 18790 — the same rule the plugin card's
417
+ // "Open" button uses. Hardcoding it meant any project whose gateway is not on 18789 got a tunnel
418
+ // command missing the dashboard port, and the dashboard then simply failed to load with no clue why.
419
+ const gwPort = Number(portOf(s.gatewayUrl, 18789));
420
+ const ports = Array.from(new Set([r.uiPort || 51789, gwPort, Number(portOf(s.routerUrl, 20128)), gwPort + 1]));
417
421
  const cmd = `ssh ${ports.map((p) => `-L ${p}:127.0.0.1:${p}`).join(' ')} ${user}@${host}`;
418
422
  return `<details class="card" style="margin-top:12px;" ${r.headless ? 'open' : ''}>
419
423
  <summary style="cursor:pointer; font-weight:600;">🌐 ${t('Mở từ máy khác (VPS/server)', 'Open from another machine (VPS/server)')}</summary>
@@ -1011,11 +1015,7 @@ function botListPanel(bots) {
1011
1015
  const listItems = bots.map(b => {
1012
1016
  const role = (b.role || b.desc || b.description || '').trim() || t('Tr\u1ee3 l\u00fd OpenClaw','OpenClaw assistant');
1013
1017
  const isZalo = b.channel === 'zalo-personal';
1014
- const health = isZalo ? zaloAccountHealth(b) : null;
1015
- const connection = !health ? t('Chưa rõ','Unknown') : health.running ? t('Đã kết nối','Connected') : health.lastError ? t('Mất kết nối','Disconnected') : t('Đang kết nối','Connecting');
1016
- const login = !health ? t('Chưa rõ','Unknown') : health.sessionSaved ? t('Đã đăng nhập','Logged in') : t('Chưa đăng nhập','Not logged in');
1017
- const connectionTone = health?.running ? 'ok' : health?.lastError ? 'bad' : 'warn';
1018
- const loginTone = health?.sessionSaved ? 'ok' : health ? 'bad' : 'warn';
1018
+ const { connection, login, connectionTone, loginTone } = zaloHealthBadges(b);
1019
1019
  return `<article class="bot-item ${state.activeBotId===b.id?'active':''}" data-bot-id="${escapeHtml(b.id)}"><div class="bot-item-actions"><button class="bot-edit" data-edit-bot="${escapeHtml(b.id)}" title="${t('Sửa bot','Edit bot')}" aria-label="${t('Sửa bot','Edit bot')}">${actionIcon('edit')}</button><button class="bot-delete" data-delete-bot="${escapeHtml(b.id)}" title="${t('X\u00f3a bot','Delete bot')}" aria-label="${t('X\u00f3a bot','Delete bot')}">&times;</button></div><div class="bot-item-title"><b>${escapeHtml(b.name)}</b></div><small title="${escapeHtml(role)}">${escapeHtml(role)}</small>${isZalo ? `<div class="zalo-bot-health"><div><span>${t('Kết nối','Connection')}</span><em class="${connectionTone}">${connection}</em></div><div><span>${t('Đăng nhập','Login')}</span><em class="${loginTone}">${login}</em></div></div>` : ''}</article>`;
1020
1020
  });
1021
1021
  listItems.push(`
@@ -1623,7 +1623,10 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1623
1623
  body.projectDir = activeProjectDir();
1624
1624
  body.channel = state.botChannel || 'telegram';
1625
1625
  body.userTimezone = state.tz;
1626
- if (body.channel === 'zalo-personal') {
1626
+ // Only the CREATE flow starts a Zalo QR login (the server kicks it off and sets
1627
+ // loginStarted). Editing a bot (rename, persona...) must never pop the QR modal —
1628
+ // the session is already saved and the PUT endpoint starts no login.
1629
+ if (body.channel === 'zalo-personal' && !state.botEditId) {
1627
1630
  state.botModalOpen = false;
1628
1631
  state.zaloLoginOpen = true;
1629
1632
  state.zaloQrDataUrl = '';
@@ -1718,6 +1721,24 @@ function zaloAccountHealth(bot) {
1718
1721
  || accounts.find((account) => account.accountId === (bot?.accountId || 'default'))
1719
1722
  || null;
1720
1723
  }
1724
+ // Connection/login labels for one bot. lastError wins over running: the gateway keeps
1725
+ // running=true while the Zalo listener is stuck in a retry loop (e.g. "Đăng nhập thất
1726
+ // bại"), so a running-first check painted dead bots green.
1727
+ function zaloHealthBadges(bot) {
1728
+ const health = bot?.channel === 'zalo-personal' ? zaloAccountHealth(bot) : null;
1729
+ const loginFailed = !!health?.lastError && /đăng nhập|log ?in|auth|credential|session/i.test(String(health.lastError));
1730
+ const connection = !health ? t('Chưa rõ','Unknown')
1731
+ : health.lastError ? t('Mất kết nối','Disconnected')
1732
+ : health.running ? t('Đã kết nối','Connected')
1733
+ : t('Đang kết nối','Connecting');
1734
+ const login = !health ? t('Chưa rõ','Unknown')
1735
+ : loginFailed ? t('Phiên hết hạn','Session expired')
1736
+ : health.sessionSaved ? t('Đã đăng nhập','Logged in')
1737
+ : t('Chưa đăng nhập','Not logged in');
1738
+ const connectionTone = !health ? 'warn' : health.lastError ? 'bad' : health.running ? 'ok' : 'warn';
1739
+ const loginTone = !health ? 'warn' : loginFailed ? 'bad' : health.sessionSaved ? 'ok' : 'bad';
1740
+ return { health, connection, login, connectionTone, loginTone };
1741
+ }
1721
1742
  function zaloToolbar(channelBots = []) {
1722
1743
  const active = channelBots.find((bot) => bot.id === state.activeBotId) || channelBots[0];
1723
1744
  const health = zaloAccountHealth(active);
@@ -1729,6 +1750,42 @@ async function loadZaloHealth(silent=false){
1729
1750
  try { state.zaloHealth = await api('/api/zalo/health' + projectQuery()); } catch (_) { state.zaloHealth = null; }
1730
1751
  if (!silent) render();
1731
1752
  }
1753
+ // Patch the connection/login badges of the rendered bot cards in place. render() rebuilds
1754
+ // the whole panel (killing focus/scroll), so the auto-refresh below must never call it.
1755
+ function updateZaloHealthDom() {
1756
+ const bots = state.install?.bots || [];
1757
+ document.querySelectorAll('.bot-item[data-bot-id] .zalo-bot-health').forEach((wrap) => {
1758
+ const id = wrap.closest('[data-bot-id]')?.dataset.botId;
1759
+ const bot = bots.find((b) => b.id === id);
1760
+ if (!bot) return;
1761
+ const badges = zaloHealthBadges(bot);
1762
+ const ems = wrap.querySelectorAll('em');
1763
+ if (ems[0]) { ems[0].className = badges.connectionTone; ems[0].textContent = badges.connection; }
1764
+ if (ems[1]) { ems[1].className = badges.loginTone; ems[1].textContent = badges.login; }
1765
+ });
1766
+ }
1767
+ // Live status: poll the health endpoint while Zalo bots are on screen (the server caches
1768
+ // the ~3s CLI probe with a short TTL, so this stays cheap) and patch the badges in place —
1769
+ // no more pressing "Làm mới" to see a bot drop or come back.
1770
+ const ZALO_HEALTH_POLL_MS = 10000;
1771
+ let zaloHealthPollBusy = false;
1772
+ // Debounced instant refresh for log-visible state changes (login saved, container
1773
+ // restarted...) so the badges flip within ~2s instead of waiting out a poll tick.
1774
+ let zaloHealthRefreshTimer = null;
1775
+ function scheduleZaloHealthRefresh() {
1776
+ if (zaloHealthRefreshTimer) return;
1777
+ zaloHealthRefreshTimer = setTimeout(async () => {
1778
+ zaloHealthRefreshTimer = null;
1779
+ try { await loadZaloHealth(true); updateZaloHealthDom(); } catch (_) {}
1780
+ }, 1500);
1781
+ }
1782
+ setInterval(async () => {
1783
+ if (document.hidden || zaloHealthPollBusy) return;
1784
+ if (!activeProjectDir()) return;
1785
+ if (!document.querySelector('.bot-item[data-bot-id] .zalo-bot-health')) return;
1786
+ zaloHealthPollBusy = true;
1787
+ try { await loadZaloHealth(true); updateZaloHealthDom(); } finally { zaloHealthPollBusy = false; }
1788
+ }, ZALO_HEALTH_POLL_MS);
1732
1789
  async function loadFeatureFlags(silent=false){
1733
1790
  const botId=currentBotId();
1734
1791
  if (!activeProjectDir()) { state.featureFlags = {}; state.featureInstalled = {}; state.featureVersions = {}; if (!silent) render(); return; }
@@ -1784,6 +1841,7 @@ function appendLogLine(line) {
1784
1841
  });
1785
1842
  return;
1786
1843
  }
1844
+ if (/\[zalo-connect\].*(login saved|listener (connected|start failed)|restarted|login failed|logged out)/i.test(line)) scheduleZaloHealthRefresh();
1787
1845
  const html = `<p>${escapeHtml(line)}</p>`;
1788
1846
  if (state.zaloLoginOpen || /\[zalo-connect\]|zalo|qr|login|scan/i.test(line)) {
1789
1847
  state.zaloLoginLines.push(cleanTerminalLine(line));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-openclaw-bot",
3
- "version": "5.15.4",
3
+ "version": "5.16.1",
4
4
  "description": "Interactive CLI installer for OpenClaw Bot",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {