create-openclaw-bot 5.15.2 → 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.
@@ -277,11 +277,19 @@
277
277
  }
278
278
 
279
279
  // ── gateway ──────────────────────────────────────────────────────────────
280
+ // Docker MUST bind 0.0.0.0 inside the container to be reachable at all, but that is contained:
281
+ // compose publishes the port as `127.0.0.1:<port>:<port>` (docker-gen), so the host still only
282
+ // answers on loopback. A NATIVE gateway has no port mapping to hide behind — 0.0.0.0 there puts
283
+ // it straight on the VPS's public interface, with the auth token crossing the wire in plaintext
284
+ // (the gateway speaks plain HTTP/WS) and, on a fresh VPS, no firewall in front of it. The
285
+ // `osChoice === 'vps'` clause predates native mode; keep native on loopback and reach it through
286
+ // an SSH tunnel, which is what docker-on-a-VPS effectively does too.
287
+ const openGatewayBind = deployMode === 'docker' || (osChoice === 'vps' && deployMode !== 'native');
280
288
  cfg.gateway = {
281
289
  port: gatewayPort,
282
290
  mode: 'local',
283
- bind: (deployMode === 'docker' || osChoice === 'vps') ? 'custom' : 'loopback',
284
- ...(deployMode === 'docker' || osChoice === 'vps' ? { customBindHost: '0.0.0.0' } : {}),
291
+ bind: openGatewayBind ? 'custom' : 'loopback',
292
+ ...(openGatewayBind ? { customBindHost: '0.0.0.0' } : {}),
285
293
  controlUi: {
286
294
  allowedOrigins: gatewayAllowedOrigins.length > 0
287
295
  ? gatewayAllowedOrigins
@@ -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',
@@ -0,0 +1,179 @@
1
+ // @ts-nocheck
2
+ // PowerShell backend for host UI automation on Windows.
3
+ //
4
+ // Windows has no xdotool and no bundled CLI for input, so the actions are done the way the
5
+ // no-dependency tools do it: P/Invoke into user32.dll for the pointer and keys (SetCursorPos /
6
+ // mouse_event / keybd_event), System.Windows.Forms.SendKeys for text, System.Drawing's
7
+ // CopyFromScreen for captures, and Get/Set-Clipboard for the clipboard. Shipping this as one
8
+ // version-stamped .ps1 keeps the quoting sane: the server passes typed parameters, never a
9
+ // composed command line.
10
+ (function (root) {
11
+ const HOST_UI_PS1_VERSION = '1';
12
+
13
+ const HOST_UI_PS1 = String.raw`# OpenClaw host UI helper — version __VERSION__
14
+ # Generated by openclaw-setup. Edits are overwritten when the version changes.
15
+ param(
16
+ [Parameter(Mandatory = $true)][string]$Action,
17
+ [int]$X = -1,
18
+ [int]$Y = -1,
19
+ [int]$ToX = -1,
20
+ [int]$ToY = -1,
21
+ [int]$Amount = 3,
22
+ [string]$Text = '',
23
+ [string]$Button = 'left',
24
+ [int]$Clicks = 1,
25
+ [string]$Path = '',
26
+ [string]$Title = ''
27
+ )
28
+
29
+ $ErrorActionPreference = 'Stop'
30
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
31
+
32
+ if (-not ('OpenClawUi' -as [type])) {
33
+ Add-Type -Namespace '' -Name OpenClawUi -MemberDefinition @'
34
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
35
+ [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, int extra);
36
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd);
37
+ [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int cmd);
38
+ '@
39
+ }
40
+
41
+ $MOUSEEVENTF = @{
42
+ leftdown = 0x0002; leftup = 0x0004;
43
+ rightdown = 0x0008; rightup = 0x0010;
44
+ middledown = 0x0020; middleup = 0x0040;
45
+ wheel = 0x0800; hwheel = 0x01000;
46
+ }
47
+
48
+ function Out-Json($obj) { $obj | ConvertTo-Json -Compress -Depth 6 }
49
+
50
+ function Move-Pointer([int]$px, [int]$py) {
51
+ if ($px -ge 0 -and $py -ge 0) { [OpenClawUi]::SetCursorPos($px, $py) | Out-Null; Start-Sleep -Milliseconds 40 }
52
+ }
53
+
54
+ function Invoke-Click([string]$btn, [int]$times) {
55
+ $down = $MOUSEEVENTF[($btn + 'down')]
56
+ $up = $MOUSEEVENTF[($btn + 'up')]
57
+ if (-not $down) { throw "unknown button: $btn" }
58
+ for ($i = 0; $i -lt [Math]::Max(1, $times); $i++) {
59
+ [OpenClawUi]::mouse_event($down, 0, 0, 0, 0)
60
+ [OpenClawUi]::mouse_event($up, 0, 0, 0, 0)
61
+ Start-Sleep -Milliseconds 60
62
+ }
63
+ }
64
+
65
+ # SendKeys needs its own escaping: + ^ % ~ ( ) { } [ ] are modifiers there, so literal text has
66
+ # to be wrapped in braces or the bot's message silently turns into shortcuts.
67
+ function ConvertTo-SendKeysLiteral([string]$s) {
68
+ $sb = New-Object System.Text.StringBuilder
69
+ foreach ($ch in $s.ToCharArray()) {
70
+ if ('+^%~(){}[]'.Contains($ch)) { [void]$sb.Append('{' + $ch + '}') }
71
+ elseif ($ch -eq [char]10) { [void]$sb.Append('{ENTER}') }
72
+ elseif ($ch -eq [char]9) { [void]$sb.Append('{TAB}') }
73
+ elseif ($ch -eq [char]13) { }
74
+ else { [void]$sb.Append($ch) }
75
+ }
76
+ $sb.ToString()
77
+ }
78
+
79
+ # "ctrl+shift+t" / "enter" / "cmd+a" (cmd is mapped to ctrl on Windows) → SendKeys syntax.
80
+ function ConvertTo-SendKeysCombo([string]$combo) {
81
+ $named = @{
82
+ enter = '{ENTER}'; return = '{ENTER}'; tab = '{TAB}'; esc = '{ESC}'; escape = '{ESC}';
83
+ backspace = '{BACKSPACE}'; delete = '{DELETE}'; del = '{DELETE}'; home = '{HOME}'; end = '{END}';
84
+ pageup = '{PGUP}'; pagedown = '{PGDN}'; up = '{UP}'; down = '{DOWN}'; left = '{LEFT}'; right = '{RIGHT}';
85
+ space = ' '; f1 = '{F1}'; f2 = '{F2}'; f3 = '{F3}'; f4 = '{F4}'; f5 = '{F5}'; f6 = '{F6}';
86
+ f7 = '{F7}'; f8 = '{F8}'; f9 = '{F9}'; f10 = '{F10}'; f11 = '{F11}'; f12 = '{F12}';
87
+ printscreen = '{PRTSC}'; insert = '{INSERT}';
88
+ }
89
+ $prefix = ''
90
+ $parts = $combo.Split('+') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }
91
+ $keyPart = ''
92
+ foreach ($p in $parts) {
93
+ switch ($p) {
94
+ 'ctrl' { $prefix += '^' }
95
+ 'control' { $prefix += '^' }
96
+ 'cmd' { $prefix += '^' } # macOS habit; Windows equivalent is Ctrl
97
+ 'meta' { $prefix += '^' }
98
+ 'alt' { $prefix += '%' }
99
+ 'shift' { $prefix += '+' }
100
+ 'win' { $prefix += '' } # SendKeys cannot press Win; use the Windows key combos below
101
+ default { $keyPart = $p }
102
+ }
103
+ }
104
+ if (-not $keyPart) { throw "no key in combo: $combo" }
105
+ $mapped = if ($named.ContainsKey($keyPart)) { $named[$keyPart] } else { $keyPart }
106
+ $prefix + $mapped
107
+ }
108
+
109
+ switch ($Action) {
110
+ 'screen_size' {
111
+ $b = [System.Windows.Forms.SystemInformation]::VirtualScreen
112
+ Out-Json @{ ok = $true; width = $b.Width; height = $b.Height; left = $b.Left; top = $b.Top }
113
+ }
114
+ 'screenshot' {
115
+ if (-not $Path) { throw 'screenshot needs -Path' }
116
+ $b = [System.Windows.Forms.SystemInformation]::VirtualScreen
117
+ $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
118
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
119
+ $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)
120
+ $dir = Split-Path -Parent $Path
121
+ if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
122
+ $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
123
+ $g.Dispose(); $bmp.Dispose()
124
+ Out-Json @{ ok = $true; path = $Path; width = $b.Width; height = $b.Height }
125
+ }
126
+ 'mouse_move' { Move-Pointer $X $Y; Out-Json @{ ok = $true; x = $X; y = $Y } }
127
+ 'click' { Move-Pointer $X $Y; Invoke-Click $Button $Clicks; Out-Json @{ ok = $true; button = $Button; clicks = $Clicks } }
128
+ 'drag' {
129
+ Move-Pointer $X $Y
130
+ [OpenClawUi]::mouse_event($MOUSEEVENTF['leftdown'], 0, 0, 0, 0)
131
+ Start-Sleep -Milliseconds 80
132
+ Move-Pointer $ToX $ToY
133
+ [OpenClawUi]::mouse_event($MOUSEEVENTF['leftup'], 0, 0, 0, 0)
134
+ Out-Json @{ ok = $true; from = @($X, $Y); to = @($ToX, $ToY) }
135
+ }
136
+ 'scroll' {
137
+ Move-Pointer $X $Y
138
+ $ticks = [Math]::Max(1, [Math]::Abs($Amount))
139
+ $delta = if ($Amount -lt 0) { -120 } else { 120 }
140
+ for ($i = 0; $i -lt $ticks; $i++) {
141
+ [OpenClawUi]::mouse_event($MOUSEEVENTF['wheel'], 0, 0, [uint32]([int]$delta -band 0xFFFFFFFF), 0)
142
+ Start-Sleep -Milliseconds 40
143
+ }
144
+ Out-Json @{ ok = $true; amount = $Amount }
145
+ }
146
+ 'type' {
147
+ [System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysLiteral $Text))
148
+ Out-Json @{ ok = $true; typed = $Text.Length }
149
+ }
150
+ 'key' {
151
+ foreach ($combo in ($Text -split '\s+' | Where-Object { $_ })) {
152
+ [System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysCombo $combo))
153
+ Start-Sleep -Milliseconds 60
154
+ }
155
+ Out-Json @{ ok = $true; keys = $Text }
156
+ }
157
+ 'clipboard_get' { Out-Json @{ ok = $true; text = (Get-Clipboard -Raw) } }
158
+ 'clipboard_set' { Set-Clipboard -Value $Text; Out-Json @{ ok = $true; length = $Text.Length } }
159
+ 'windows' {
160
+ $list = Get-Process | Where-Object { $_.MainWindowTitle } |
161
+ Select-Object -First 40 @{n = 'title'; e = { $_.MainWindowTitle } }, @{n = 'process'; e = { $_.ProcessName } }, Id
162
+ Out-Json @{ ok = $true; windows = @($list) }
163
+ }
164
+ 'focus' {
165
+ if (-not $Title) { throw 'focus needs -Title' }
166
+ $proc = Get-Process | Where-Object { $_.MainWindowTitle -like "*$Title*" } | Select-Object -First 1
167
+ if (-not $proc) { Out-Json @{ ok = $false; error = "no window matching: $Title" }; break }
168
+ [OpenClawUi]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null # SW_RESTORE
169
+ [OpenClawUi]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null
170
+ Out-Json @{ ok = $true; focused = $proc.MainWindowTitle }
171
+ }
172
+ default { Out-Json @{ ok = $false; error = "unknown action: $Action" } }
173
+ }
174
+ `.replace('__VERSION__', HOST_UI_PS1_VERSION);
175
+
176
+ const api = { HOST_UI_PS1, HOST_UI_PS1_VERSION };
177
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
178
+ root.__openclawHostUiPs1 = api;
179
+ })(typeof globalThis !== 'undefined' ? globalThis : this);
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.2",
3
+ "version": "5.16.0",
4
4
  "description": "Interactive CLI installer for OpenClaw Bot",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {