create-openclaw-bot 5.15.4 → 5.16.0

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.0-0EA5E9?style=for-the-badge" alt="Version 5.16.0" /></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.0
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.0-0EA5E9?style=for-the-badge" alt="Version 5.16.0" /></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.0
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.
@@ -2767,6 +2767,10 @@ async function ocDaemon(projectDir, verb, extraArgs = []) {
2767
2767
  * stop+start that raced the migration lease all over again.
2768
2768
  */
2769
2769
  async function restartNativeRuntime(projectDir) {
2770
+ // Every restart is a chance to repair a project installed before these two fixes existed — both
2771
+ // calls are no-ops once the service env is complete and the stray files have been adopted.
2772
+ await adoptStrayNativeHome(projectDir).catch(() => {});
2773
+ await syncNativeServiceEnv(projectDir).catch(() => {});
2770
2774
  const stopStart = async () => {
2771
2775
  await ocDaemon(projectDir, 'stop');
2772
2776
  return ocDaemon(projectDir, 'start');
@@ -2799,6 +2803,102 @@ async function restartNativeRuntime(projectDir) {
2799
2803
  return true;
2800
2804
  }
2801
2805
 
2806
+ /**
2807
+ * Make the generated service carry everything nativeEnv() promises.
2808
+ *
2809
+ * `openclaw daemon install` propagates only a fixed allow-list into the service it writes:
2810
+ * OPENCLAW_STATE_DIR survives, but OPENCLAW_HOME does NOT — verified on both a systemd user unit and
2811
+ * a launchd env-wrapper. Anything resolving paths from OPENCLAW_HOME then falls back to `~/.openclaw`
2812
+ * and writes OUTSIDE the project. zalo-connect is the visible casualty: it stages inbound files and
2813
+ * its Zalo session credentials under the wrong home, so a PDF sent to the bot lands somewhere the
2814
+ * agent's workspace cannot reach ("em chưa trích xuất được nội dung từ PDF") and the session sits in
2815
+ * a different home from the config that describes it.
2816
+ *
2817
+ * Idempotent: keys already present are left alone, so this is safe to run on every restart and it
2818
+ * self-heals projects created before the fix.
2819
+ */
2820
+ async function syncNativeServiceEnv(projectDir) {
2821
+ if (!isNativeProject(projectDir)) return [];
2822
+ const want = nativeEnv(projectDir);
2823
+ const label = nativeServiceLabel(projectDir);
2824
+
2825
+ if (process.platform === 'linux') {
2826
+ const unit = `${label}.service`;
2827
+ const shown = await runCapture('systemctl', ['--user', 'show', '-p', 'FragmentPath', '--value', unit], { shell: false, timeout: 10000 });
2828
+ const path = String(shown.stdout || '').trim() || join(os.homedir(), '.config', 'systemd', 'user', unit);
2829
+ if (!existsSync(path)) return [];
2830
+ const lines = (await fsp.readFile(path, 'utf8')).split('\n');
2831
+ const have = new Set();
2832
+ for (const line of lines) {
2833
+ const m = line.trim().match(/^Environment="?([A-Z_0-9]+)=/);
2834
+ if (m) have.add(m[1]);
2835
+ }
2836
+ const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
2837
+ if (!missing.length) return [];
2838
+ // Insert after the last existing Environment= line so the additions stay inside [Service].
2839
+ let at = -1;
2840
+ lines.forEach((line, i) => { if (line.startsWith('Environment=')) at = i; });
2841
+ if (at < 0) at = lines.findIndex((line) => line.trim() === '[Service]');
2842
+ if (at < 0) return [];
2843
+ lines.splice(at + 1, 0, ...missing.map(([k, v]) => `Environment=${k}=${v}`));
2844
+ await fsp.copyFile(path, `${path}.bak`).catch(() => {});
2845
+ await fsp.writeFile(path, lines.join('\n'), 'utf8');
2846
+ await run('systemctl', ['--user', 'daemon-reload'], {}).catch(() => {});
2847
+ sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
2848
+ return missing.map(([k]) => k);
2849
+ }
2850
+
2851
+ if (process.platform === 'darwin') {
2852
+ // launchd runs the gateway through an env-wrapper that sources this file, so patching it is the
2853
+ // launchd equivalent of adding Environment= lines to a unit.
2854
+ const envFile = join(projectDir, '.openclaw', 'service-env', `${label}.env`);
2855
+ if (!existsSync(envFile)) return [];
2856
+ const body = await fsp.readFile(envFile, 'utf8');
2857
+ const have = new Set([...body.matchAll(/^\s*export\s+([A-Z_0-9]+)=/gm)].map((m) => m[1]));
2858
+ const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
2859
+ if (!missing.length) return [];
2860
+ const added = missing.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`).join('\n');
2861
+ await fsp.copyFile(envFile, `${envFile}.bak`).catch(() => {});
2862
+ await fsp.writeFile(envFile, `${body.replace(/\n*$/, '')}\n${added}\n`, 'utf8');
2863
+ sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
2864
+ return missing.map(([k]) => k);
2865
+ }
2866
+
2867
+ return [];
2868
+ }
2869
+
2870
+ /**
2871
+ * Reunite a native project with the files an unset OPENCLAW_HOME scattered into `~/.openclaw`.
2872
+ *
2873
+ * This MUST run before syncNativeServiceEnv takes effect: once OPENCLAW_HOME is finally correct, the
2874
+ * plugin looks for its Zalo session inside the project — and if the credentials are still sitting in
2875
+ * the home directory it finds nothing and demands a fresh QR login. Copy (never move) so a failed
2876
+ * run leaves the working original in place; skip anything the project already has.
2877
+ */
2878
+ async function adoptStrayNativeHome(projectDir) {
2879
+ if (!isNativeProject(projectDir)) return [];
2880
+ const projectHome = join(projectDir, '.openclaw');
2881
+ const strayHome = join(os.homedir(), '.openclaw');
2882
+ if (resolve(strayHome) === resolve(projectHome) || !existsSync(strayHome)) return [];
2883
+ const moved = [];
2884
+ const entries = await fsp.readdir(strayHome, { withFileTypes: true }).catch(() => []);
2885
+ for (const entry of entries) {
2886
+ // `state` is deliberately excluded: the project has its own live database and merging two
2887
+ // sqlite files is not something a copy can do correctly.
2888
+ const isCreds = /^zalo-connect-credentials.*\.json$/.test(entry.name);
2889
+ if (!isCreds && entry.name !== 'media') continue;
2890
+ const from = join(strayHome, entry.name);
2891
+ const to = join(projectHome, entry.name);
2892
+ if (existsSync(to)) continue;
2893
+ await fsp.cp(from, to, { recursive: true }).catch(() => {});
2894
+ if (existsSync(to)) moved.push(entry.name);
2895
+ }
2896
+ if (moved.length) {
2897
+ sendLog(`[migrate] Native: adopted ${moved.join(', ')} from ${strayHome} (written there while OPENCLAW_HOME was unset).`);
2898
+ }
2899
+ return moved;
2900
+ }
2901
+
2802
2902
  /**
2803
2903
  * `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
2804
2904
  * unit — and a user manager without linger is torn down when that user's last session exits. On a
@@ -2836,6 +2936,16 @@ async function ensureSystemdLinger() {
2836
2936
  */
2837
2937
  async function ensureNativePlugins(projectDir, { restart = false } = {}) {
2838
2938
  if (!isNativeProject(projectDir)) return [];
2939
+ // Same cleanup the container entrypoint does (docker-gen.js): an interrupted `plugins install`
2940
+ // leaves extensions/.openclaw-install-stage-XXXXXX behind, and it still carries a plugin manifest —
2941
+ // so the gateway logs "duplicate plugin id detected" every boot and a stale build competes with the
2942
+ // real one for the same id. Native has no entrypoint, so it has to happen here.
2943
+ const extRoot = join(projectDir, '.openclaw', 'extensions');
2944
+ for (const entry of await fsp.readdir(extRoot, { withFileTypes: true }).catch(() => [])) {
2945
+ if (!entry.isDirectory() || !entry.name.startsWith('.openclaw-install-stage-')) continue;
2946
+ await fsp.rm(join(extRoot, entry.name), { recursive: true, force: true }).catch(() => {});
2947
+ sendLog(`[native] removed abandoned plugin staging dir ${entry.name}`);
2948
+ }
2839
2949
  let cfg = {};
2840
2950
  try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
2841
2951
  // learning-memory backs plugins.slots.contextEngine for every bot; zalo-connect only when a bot
@@ -2972,6 +3082,11 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
2972
3082
  const env = nativeEnv(projectDir);
2973
3083
  await ensureSystemdLinger();
2974
3084
  await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
3085
+ // Order is load-bearing: adopt the stray files FIRST, then complete the service env. The other way
3086
+ // round, the gateway boots with a corrected OPENCLAW_HOME, finds no Zalo session there, and asks
3087
+ // for a new QR scan even though a perfectly good session exists in the home directory.
3088
+ await adoptStrayNativeHome(projectDir).catch((e) => sendLog(`[migrate] stray home skipped: ${e.message}`));
3089
+ await syncNativeServiceEnv(projectDir).catch((e) => sendLog(`[native] service env sync skipped: ${e.message}`));
2975
3090
  await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2976
3091
  // Let the first boot finish its state migrations here, while nothing else is competing for the
2977
3092
  // lease. Every later action (create bot, install plugin) then restarts a settled gateway.
@@ -6658,4 +6773,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
6658
6773
  ]).catch(() => {});
6659
6774
  }
6660
6775
 
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 };
6776
+ 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 };
@@ -268,7 +268,13 @@ If setup reported a plugin install error, run this after the bot is running:
268
268
  {
269
269
  id: 'smart-route',
270
270
  name: 'Smart Proxy (Auto Route)',
271
- contextWindow: 200000,
271
+ // smart-route fans out to whatever free upstreams the operator's combo holds, and the
272
+ // SMALLEST window in that pool is the real ceiling — many free models stop at 128k.
273
+ // Declaring 200k let sessions grow past what the route could actually accept: once
274
+ // full, even the compaction summarize call overflowed and every turn died with
275
+ // "auto-compaction could not recover" until /new. 131072 keeps compaction triggering
276
+ // (window - reserveTokens) while the summarize request still fits everywhere.
277
+ contextWindow: 131072,
272
278
  maxTokens: 8192,
273
279
  input: ['text', 'image'],
274
280
  },
@@ -272,8 +272,12 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
272
272
  // • imageMaxDimensionPx / imageQuality / contextLimits.toolResultMaxChars → keep one
273
273
  // heavy turn (deep research, 4K chart read-back) from overflowing the context
274
274
  // window mid tool-loop, which cannot be compacted and poisons the session.
275
+ // • smart-route contextWindow 200000 → 131072: the old declared window exceeded the
276
+ // smallest upstream in typical free-model combos, so full sessions deadlocked —
277
+ // the compaction summarize call itself overflowed and only /new recovered. Only the
278
+ // exact setup-written 200000 is rewritten; an operator's custom value is left alone.
275
279
  // 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));}`;
280
+ 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;}}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;ch=true;}}}if(ch)fs.writeFileSync(p,JSON.stringify(c,null,2));}`;
277
281
  // Companion backfill for the same older projects: their TOOLS.md was generated before the
278
282
  // skill-authoring / long-turn guidance existed, and workspace files are only written when a
279
283
  // bot is created — so a rebuild alone leaves the assistant stopping at "proposal awaiting
@@ -288,6 +292,17 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
288
292
  'export OPENCLAW_HOME="${OPENCLAW_HOME:-$PWD/.openclaw}"',
289
293
  'export OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-$OPENCLAW_HOME}"',
290
294
  'mkdir -p "$OPENCLAW_HOME" "$OPENCLAW_STATE_DIR"',
295
+ // `openclaw plugins install` unpacks into extensions/.openclaw-install-stage-XXXXXX and removes
296
+ // it when it finishes. An interrupted install leaves the staging copy behind — and it still
297
+ // carries a plugin manifest, so the gateway logs "duplicate plugin id detected" on every boot
298
+ // and a stale build competes with the real one for the same id. Found on a production host: a
299
+ // zalo-connect 3.0.7 stage dir shadowing 3.0.17 for a week. Nothing is installing at entrypoint
300
+ // time, so any stage dir here is by definition abandoned.
301
+ 'for stage in "$OPENCLAW_HOME"/extensions/.openclaw-install-stage-*; do',
302
+ ' [ -d "$stage" ] || continue',
303
+ ' echo "[entrypoint] removing abandoned plugin staging dir $(basename "$stage")"',
304
+ ' rm -rf "$stage"',
305
+ 'done',
291
306
  'if [ "$OPENCLAW_STATE_DIR" != "$OPENCLAW_HOME" ]; then',
292
307
  ' for path in "$OPENCLAW_HOME"/*; do',
293
308
  ' [ -e "$path" ] || continue',
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.0",
4
4
  "description": "Interactive CLI installer for OpenClaw Bot",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {