a2acalling 0.6.44 → 0.6.46

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.
@@ -0,0 +1,246 @@
1
+ # Bugfix Plan: A2A-22 + A2A-24
2
+
3
+ > **For Claude:** Execute these fixes on branch `fix/bugs-22-23-24` in `/root/a2acalling`
4
+
5
+ **Goal:** Fix quickstart onboarding gaps (identity, disclosure levels, goals) and surface network warnings during quickstart.
6
+
7
+ **A2A-23 is already fixed** in commit d3fe14e. This plan covers A2A-22 and A2A-24 only.
8
+
9
+ ---
10
+
11
+ ## Bug A2A-22: Quickstart onboarding gaps
12
+
13
+ ### Fix 1: Disclosure levels inverted on friends/family tiers
14
+
15
+ **File:** `bin/cli.js` ~line 546-553 (inside `handleDisclosureSubmit`)
16
+
17
+ **Problem:** Friends and family tiers are set to `disclosure: 'minimal'` when they should be more open than public.
18
+
19
+ **Fix:** Change disclosure levels to escalate: public→'minimal', friends→'standard', family→'full'.
20
+
21
+ ```javascript
22
+ // BEFORE (broken):
23
+ config.setTier('public', { topics: ..., disclosure: 'public' });
24
+ config.setTier('friends', { topics: ..., disclosure: 'minimal' });
25
+ config.setTier('family', { topics: ..., disclosure: 'minimal' });
26
+
27
+ // AFTER (fixed):
28
+ config.setTier('public', { topics: ..., disclosure: 'minimal' });
29
+ config.setTier('friends', { topics: ..., disclosure: 'standard' });
30
+ config.setTier('family', { topics: ..., disclosure: 'full' });
31
+ ```
32
+
33
+ **Rationale:** Public tier should be most restrictive (minimal). Friends get more (standard). Family gets full disclosure. This matches the trust hierarchy.
34
+
35
+ ### Fix 2: Extract identity from disclosure submission
36
+
37
+ **File:** `bin/cli.js` ~line 564-568 (inside `handleDisclosureSubmit`)
38
+
39
+ **Problem:** Agent name defaults to 'my-agent', owner name is set to agent name. The disclosure manifest often contains the real owner name in `personality_notes` or the submission JSON, but it's never extracted.
40
+
41
+ **Fix:** After parsing the disclosure JSON, extract identity fields:
42
+ 1. Check if the submission JSON has `agent_name` or `owner_name` fields (the extraction prompt should request these)
43
+ 2. Fall back to extracting from `personality_notes` (often contains "I'm [Name]" or similar)
44
+ 3. Fall back to the OS username
45
+ 4. Use extracted values for config and token creation
46
+
47
+ In `handleDisclosureSubmit`, after line ~527 where `result` is validated, add:
48
+
49
+ ```javascript
50
+ // Extract identity from disclosure submission
51
+ const ownerName = result.owner_name
52
+ || result.manifest?.owner_name
53
+ || extractNameFromPersonality(result.manifest?.personality_notes)
54
+ || process.env.USER
55
+ || 'Agent Owner';
56
+
57
+ const agentName = args.flags.name
58
+ || result.agent_name
59
+ || config.getAgent().name
60
+ || process.env.A2A_AGENT_NAME
61
+ || `${ownerName}'s Agent`;
62
+
63
+ // Save identity to config
64
+ config.setAgent({ name: agentName, owner_name: ownerName });
65
+ ```
66
+
67
+ Add a helper function (before `handleDisclosureSubmit`):
68
+
69
+ ```javascript
70
+ function extractNameFromPersonality(notes) {
71
+ if (!notes || typeof notes !== 'string') return null;
72
+ // Look for patterns like "I'm Ben", "My name is Ben", "Owner: Ben"
73
+ const patterns = [
74
+ /(?:I'm|I am|My name is|Name:|Owner:)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/,
75
+ /^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:is|here|speaking)/
76
+ ];
77
+ for (const p of patterns) {
78
+ const m = notes.match(p);
79
+ if (m && m[1]) return m[1].trim();
80
+ }
81
+ return null;
82
+ }
83
+ ```
84
+
85
+ ### Fix 3: Use extracted identity in token creation
86
+
87
+ **File:** `bin/cli.js` ~line 572-582
88
+
89
+ **Problem:** Token uses `agentName` as both name and owner, with hardcoded goals.
90
+
91
+ **Fix:** Use the extracted `ownerName` for the token owner field:
92
+
93
+ ```javascript
94
+ // BEFORE:
95
+ const { token } = store.create({
96
+ name: agentName,
97
+ owner: agentName, // wrong — should be owner name
98
+ ...
99
+ });
100
+
101
+ // AFTER:
102
+ const { token } = store.create({
103
+ name: agentName,
104
+ owner: ownerName,
105
+ ...
106
+ });
107
+ ```
108
+
109
+ ### Fix 4: Sync disclosure objectives to token goals
110
+
111
+ **File:** `bin/cli.js` ~line 579
112
+
113
+ **Problem:** Token goals are hardcoded `['grow-network', 'find-collaborators', 'build-in-public']` instead of derived from disclosure.
114
+
115
+ **Fix:** Extract objectives from the disclosure manifest and use them as goals:
116
+
117
+ ```javascript
118
+ // Extract goals from disclosure objectives
119
+ const disclosureObjectives = (result.manifest?.objectives || [])
120
+ .map(o => typeof o === 'string' ? o : (o && o.objective || ''))
121
+ .map(s => s.trim().toLowerCase().replace(/\s+/g, '-').slice(0, 60))
122
+ .filter(Boolean);
123
+
124
+ const tokenGoals = disclosureObjectives.length > 0
125
+ ? disclosureObjectives.slice(0, 5)
126
+ : ['grow-network', 'find-collaborators', 'build-in-public'];
127
+ ```
128
+
129
+ Then use `tokenGoals` in the store.create call:
130
+ ```javascript
131
+ allowedGoals: tokenGoals,
132
+ ```
133
+
134
+ Also sync goals to tier config:
135
+ ```javascript
136
+ config.setTier('public', {
137
+ topics: getTierTopics(tiersData.public),
138
+ goals: tokenGoals,
139
+ disclosure: 'minimal'
140
+ });
141
+ ```
142
+
143
+ ### Fix 5: Update extraction prompt to request identity fields
144
+
145
+ **File:** `src/lib/disclosure.js` — the `buildExtractionPrompt` function
146
+
147
+ **Problem:** The extraction prompt tells the agent what to scan but doesn't ask for `owner_name` or `agent_name` fields in the JSON output.
148
+
149
+ **Fix:** Add `owner_name` and `agent_name` to the required JSON schema in the prompt. Look for the JSON structure section and add:
150
+
151
+ ```
152
+ "owner_name": "The human owner's real name (extracted from USER.md, git config, etc.)",
153
+ "agent_name": "The agent's display name (extracted from USER.md or workspace context)",
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Bug A2A-24: Surface network warnings during quickstart
159
+
160
+ ### Fix 6: Add connectivity check after server start
161
+
162
+ **File:** `bin/cli.js` — quickstart command, after server start (~line 2017-2019)
163
+
164
+ **Problem:** The verify URL is printed but never executed. No connectivity feedback.
165
+
166
+ **Fix:** After the server starts and the verify URL is printed, actually run the connectivity check:
167
+
168
+ ```javascript
169
+ // After line: console.log(`\n Verify: curl -s ${verifyUrl}`);
170
+ // Add actual verification:
171
+ const http = require('http');
172
+ const verifyOk = await new Promise(resolve => {
173
+ const req = http.request({
174
+ hostname: '127.0.0.1',
175
+ port: serverPort,
176
+ path: '/api/a2a/ping',
177
+ method: 'GET',
178
+ timeout: 2000
179
+ }, (res) => {
180
+ res.resume();
181
+ resolve(res.statusCode === 200);
182
+ });
183
+ req.on('error', () => resolve(false));
184
+ req.on('timeout', () => { req.destroy(); resolve(false); });
185
+ req.end();
186
+ });
187
+
188
+ if (verifyOk) {
189
+ console.log(' ✅ Local connectivity verified');
190
+ } else {
191
+ console.log(' ⚠️ Local server check failed — server may still be starting');
192
+ }
193
+ ```
194
+
195
+ ### Fix 7: Surface invite-host warnings during quickstart
196
+
197
+ **File:** `bin/cli.js` — quickstart command, after server start
198
+
199
+ **Problem:** `resolveInviteHost()` returns warnings about NAT/firewall, but they're only shown during `a2a create`, not during quickstart.
200
+
201
+ **Fix:** After `publicHost` is set in quickstart, call `resolveInviteHost` and print its warnings:
202
+
203
+ ```javascript
204
+ // After publicHost is determined, resolve and show warnings
205
+ try {
206
+ const { resolveInviteHost } = require('../src/lib/invite-host');
207
+ const resolved = await resolveInviteHost({
208
+ hostname: publicHost,
209
+ port: serverPort
210
+ });
211
+ if (resolved.warnings && resolved.warnings.length) {
212
+ console.log('\n ━━━ Network Warnings ━━━');
213
+ for (const w of resolved.warnings) {
214
+ console.warn(` ⚠️ ${w}`);
215
+ }
216
+ }
217
+ } catch (_) {}
218
+ ```
219
+
220
+ ### Fix 8: Add non-standard port warning with reverse proxy guidance
221
+
222
+ **File:** `bin/cli.js` — quickstart, already partially handled
223
+
224
+ **Assessment:** Looking at the code at lines 2008-2014, this is already partially implemented — when the user chooses 'continue' on a non-standard port, it prints a brief reminder about reverse proxy setup. But it could be more prominent.
225
+
226
+ **Fix:** Enhance the existing warning. After the "Running on port X (non-standard)" message, add:
227
+
228
+ ```javascript
229
+ console.log('');
230
+ console.log(' ⚠️ Remote agents using your invite URL will try port 80 by default.');
231
+ console.log(' Without a reverse proxy, inbound calls on port 80 will fail silently.');
232
+ ```
233
+
234
+ ---
235
+
236
+ ## Commit Strategy
237
+
238
+ 1. First commit: A2A-22 fixes (disclosure levels, identity extraction, goals sync)
239
+ 2. Second commit: A2A-24 fixes (connectivity check, network warnings)
240
+ 3. Run `npm test` after each commit to verify no regressions
241
+
242
+ ## Testing
243
+
244
+ After all fixes:
245
+ - `npm test` — all 269+ tests must pass
246
+ - Manual verification of the logic changes (the reviewer should trace through the code paths)
@@ -0,0 +1,172 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>A2A Callbook</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'IBM Plex Sans', sans-serif;
11
+ background: linear-gradient(180deg, #eef3f8 0%, #f8f9fb 100%);
12
+ color: #1a1a2e;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ min-height: 100vh;
17
+ }
18
+ .status-card {
19
+ background: #fff;
20
+ border: 1px solid #d0d7de;
21
+ border-radius: 12px;
22
+ padding: 48px;
23
+ text-align: center;
24
+ max-width: 420px;
25
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
26
+ }
27
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
28
+ .subtitle { color: #666; font-size: 14px; margin-bottom: 24px; }
29
+ .status-indicator {
30
+ display: inline-block;
31
+ width: 10px; height: 10px;
32
+ border-radius: 50%;
33
+ margin-right: 8px;
34
+ vertical-align: middle;
35
+ }
36
+ .status-indicator.searching { background: #f59e0b; animation: pulse 1.5s infinite; }
37
+ .status-indicator.disconnected { background: #ef4444; }
38
+ .status-indicator.connected { background: #22c55e; }
39
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
40
+ .status-text { font-size: 14px; margin-bottom: 24px; color: #444; }
41
+ .port-info { font-size: 12px; color: #888; margin-bottom: 16px; font-family: monospace; }
42
+ button {
43
+ background: #1466c1; color: #fff; border: none; border-radius: 8px;
44
+ padding: 10px 24px; font-size: 14px; cursor: pointer; margin: 4px;
45
+ font-family: inherit;
46
+ }
47
+ button:hover { background: #1052a0; }
48
+ button.secondary {
49
+ background: transparent; color: #1466c1; border: 1px solid #1466c1;
50
+ }
51
+ button.secondary:hover { background: #eef3f8; }
52
+ #error-detail { color: #ef4444; font-size: 12px; margin-top: 12px; display: none; }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <div class="status-card">
57
+ <h1>A2A Callbook</h1>
58
+ <p class="subtitle">Agent-to-agent communication dashboard</p>
59
+
60
+ <div id="status-searching">
61
+ <p class="status-text">
62
+ <span class="status-indicator searching"></span>
63
+ Looking for a2a server...
64
+ </p>
65
+ <p class="port-info" id="port-info">Scanning ports: 3001, 80, 8080, 8443, 9001</p>
66
+ </div>
67
+
68
+ <div id="status-not-found" style="display:none;">
69
+ <p class="status-text">
70
+ <span class="status-indicator disconnected"></span>
71
+ Server not running
72
+ </p>
73
+ <p class="port-info" id="last-port">No a2a server found on common ports</p>
74
+ <button id="btn-start">Start Server</button>
75
+ <button id="btn-retry" class="secondary">Retry</button>
76
+ <p id="error-detail"></p>
77
+ </div>
78
+
79
+ <div id="status-connected" style="display:none;">
80
+ <p class="status-text">
81
+ <span class="status-indicator connected"></span>
82
+ Connected to server
83
+ </p>
84
+ <p class="port-info" id="connected-port"></p>
85
+ </div>
86
+ </div>
87
+
88
+ <script>
89
+ const { invoke } = window.__TAURI__.core;
90
+
91
+ async function checkServer() {
92
+ show('status-searching');
93
+ try {
94
+ const result = await invoke('discover_server');
95
+ if (result.port) {
96
+ show('status-connected');
97
+ document.getElementById('connected-port').textContent =
98
+ `localhost:${result.port}`;
99
+ // Navigate to live SPA
100
+ setTimeout(() => {
101
+ window.location.href =
102
+ `http://127.0.0.1:${result.port}/api/a2a/dashboard/` +
103
+ (window.__TAB_HASH || '');
104
+ }, 400);
105
+ } else {
106
+ show('status-not-found');
107
+ }
108
+ } catch (err) {
109
+ show('status-not-found');
110
+ const detail = document.getElementById('error-detail');
111
+ detail.textContent = err;
112
+ detail.style.display = 'block';
113
+ }
114
+ }
115
+
116
+ function show(id) {
117
+ ['status-searching', 'status-not-found', 'status-connected']
118
+ .forEach(s => document.getElementById(s).style.display = 'none');
119
+ document.getElementById(id).style.display = 'block';
120
+ }
121
+
122
+ document.getElementById('btn-start')?.addEventListener('click', async () => {
123
+ try {
124
+ await invoke('start_server');
125
+ // Wait for server to boot, then retry
126
+ setTimeout(checkServer, 2000);
127
+ } catch (err) {
128
+ const detail = document.getElementById('error-detail');
129
+ detail.textContent = `Failed to start: ${err}`;
130
+ detail.style.display = 'block';
131
+ }
132
+ });
133
+
134
+ document.getElementById('btn-retry')?.addEventListener('click', checkServer);
135
+
136
+ // Start discovery on load
137
+ checkServer();
138
+
139
+ // Listen for server disconnect/reconnect from Tauri backend
140
+ const { listen } = window.__TAURI__.event;
141
+
142
+ listen('server-status', (event) => {
143
+ const { connected, port } = event.payload;
144
+ if (!connected) {
145
+ showReconnectionOverlay();
146
+ } else {
147
+ hideReconnectionOverlay();
148
+ }
149
+ });
150
+
151
+ function showReconnectionOverlay() {
152
+ if (document.getElementById('reconnect-overlay')) return;
153
+ const overlay = document.createElement('div');
154
+ overlay.id = 'reconnect-overlay';
155
+ overlay.innerHTML = `
156
+ <div style="position:fixed;top:0;left:0;right:0;z-index:9999;
157
+ background:#fef3c7;border-bottom:2px solid #f59e0b;padding:12px 24px;
158
+ text-align:center;font-family:-apple-system,sans-serif;font-size:14px;color:#92400e;">
159
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;
160
+ background:#f59e0b;margin-right:8px;animation:pulse 1.5s infinite;vertical-align:middle;"></span>
161
+ Server disconnected — Reconnecting...
162
+ </div>`;
163
+ document.body.appendChild(overlay);
164
+ }
165
+
166
+ function hideReconnectionOverlay() {
167
+ const overlay = document.getElementById('reconnect-overlay');
168
+ if (overlay) overlay.remove();
169
+ }
170
+ </script>
171
+ </body>
172
+ </html>
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "a2a-callbook-macos",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "tauri": "cargo tauri"
7
+ }
8
+ }
@@ -0,0 +1,23 @@
1
+ [package]
2
+ name = "a2a-callbook"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [lib]
7
+ name = "a2a_callbook_lib"
8
+ crate-type = ["lib", "cdylib", "staticlib"]
9
+
10
+ [build-dependencies]
11
+ tauri-build = { version = "2", features = [] }
12
+
13
+ [dependencies]
14
+ tauri = { version = "2", features = [] }
15
+ tauri-plugin-shell = "2"
16
+ tauri-plugin-notification = "2"
17
+ tauri-plugin-deep-link = "2"
18
+ tauri-plugin-window-state = "2"
19
+ serde = { version = "1", features = ["derive"] }
20
+ serde_json = "1"
21
+ reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
22
+ tokio = { version = "1", features = ["full"] }
23
+ dirs = "6"
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ tauri_build::build()
3
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
3
+ "identifier": "default",
4
+ "description": "Default capabilities for A2A Callbook",
5
+ "windows": ["main"],
6
+ "permissions": [
7
+ "core:default",
8
+ "shell:allow-open",
9
+ "notification:default",
10
+ "notification:allow-is-permission-granted",
11
+ "notification:allow-request-permission",
12
+ "notification:allow-notify",
13
+ "deep-link:default",
14
+ "window-state:default"
15
+ ]
16
+ }
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,86 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use std::path::PathBuf;
3
+ use std::time::Duration;
4
+
5
+ const DEFAULT_PORTS: &[u16] = &[3001, 80, 8080, 8443, 9001];
6
+ const PROBE_TIMEOUT: Duration = Duration::from_millis(800);
7
+
8
+ #[derive(Debug, Serialize, Deserialize)]
9
+ pub struct DiscoveryResult {
10
+ pub port: Option<u16>,
11
+ pub source: String, // "config" | "scan" | "none"
12
+ }
13
+
14
+ #[derive(Debug, Deserialize)]
15
+ struct A2AConfig {
16
+ onboarding: Option<OnboardingConfig>,
17
+ }
18
+
19
+ #[derive(Debug, Deserialize)]
20
+ struct OnboardingConfig {
21
+ server_port: Option<u16>,
22
+ }
23
+
24
+ /// Read port from ~/.config/openclaw/a2a-config.json
25
+ pub fn read_config_port() -> Option<u16> {
26
+ let config_dir = std::env::var("A2A_CONFIG_DIR")
27
+ .or_else(|_| std::env::var("OPENCLAW_CONFIG_DIR"))
28
+ .map(PathBuf::from)
29
+ .unwrap_or_else(|_| {
30
+ dirs::home_dir()
31
+ .unwrap_or_else(|| PathBuf::from("/tmp"))
32
+ .join(".config")
33
+ .join("openclaw")
34
+ });
35
+
36
+ let config_path = config_dir.join("a2a-config.json");
37
+ let content = std::fs::read_to_string(config_path).ok()?;
38
+ let config: A2AConfig = serde_json::from_str(&content).ok()?;
39
+ config.onboarding?.server_port
40
+ }
41
+
42
+ /// Probe a single port — returns true if a2a server responds
43
+ async fn probe_port(port: u16) -> bool {
44
+ let url = format!("http://127.0.0.1:{}/api/a2a/ping", port);
45
+ let client = reqwest::Client::builder()
46
+ .timeout(PROBE_TIMEOUT)
47
+ .build();
48
+
49
+ let client = match client {
50
+ Ok(c) => c,
51
+ Err(_) => return false,
52
+ };
53
+
54
+ match client.get(&url).send().await {
55
+ Ok(resp) => resp.status().is_success(),
56
+ Err(_) => false,
57
+ }
58
+ }
59
+
60
+ /// Discover the running a2a server
61
+ pub async fn discover_server() -> DiscoveryResult {
62
+ // 1. Try config port first
63
+ if let Some(port) = read_config_port() {
64
+ if probe_port(port).await {
65
+ return DiscoveryResult {
66
+ port: Some(port),
67
+ source: "config".to_string(),
68
+ };
69
+ }
70
+ }
71
+
72
+ // 2. Scan default ports
73
+ for &port in DEFAULT_PORTS {
74
+ if probe_port(port).await {
75
+ return DiscoveryResult {
76
+ port: Some(port),
77
+ source: "scan".to_string(),
78
+ };
79
+ }
80
+ }
81
+
82
+ DiscoveryResult {
83
+ port: None,
84
+ source: "none".to_string(),
85
+ }
86
+ }
@@ -0,0 +1,64 @@
1
+ use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
2
+ use std::sync::Arc;
3
+ use std::time::Duration;
4
+ use tauri::{Emitter, Manager};
5
+
6
+ static CONNECTED: AtomicBool = AtomicBool::new(false);
7
+ static CURRENT_PORT: AtomicU16 = AtomicU16::new(0);
8
+
9
+ pub fn is_connected() -> bool {
10
+ CONNECTED.load(Ordering::Relaxed)
11
+ }
12
+
13
+ pub fn current_port() -> u16 {
14
+ CURRENT_PORT.load(Ordering::Relaxed)
15
+ }
16
+
17
+ pub fn set_connected(port: u16) {
18
+ CURRENT_PORT.store(port, Ordering::Relaxed);
19
+ CONNECTED.store(true, Ordering::Relaxed);
20
+ }
21
+
22
+ /// Start background health check loop — emits "server-status" events
23
+ pub fn start_health_monitor(app: tauri::AppHandle) {
24
+ let handle = Arc::new(app);
25
+ tokio::spawn(async move {
26
+ loop {
27
+ tokio::time::sleep(Duration::from_secs(3)).await;
28
+
29
+ let port = CURRENT_PORT.load(Ordering::Relaxed);
30
+ if port == 0 {
31
+ continue;
32
+ }
33
+
34
+ let url = format!("http://127.0.0.1:{}/api/a2a/ping", port);
35
+ let client = match reqwest::Client::builder()
36
+ .timeout(Duration::from_millis(1500))
37
+ .build() {
38
+ Ok(c) => c,
39
+ Err(_) => continue,
40
+ };
41
+
42
+ let ok = match client.get(&url).send().await {
43
+ Ok(resp) => resp.status().is_success(),
44
+ Err(_) => false,
45
+ };
46
+
47
+ let was_connected = CONNECTED.swap(ok, Ordering::Relaxed);
48
+
49
+ // Only emit on state change
50
+ if ok != was_connected {
51
+ let _ = handle.emit("server-status", serde_json::json!({
52
+ "connected": ok,
53
+ "port": port
54
+ }));
55
+ // Navigate back to loader page on disconnect so reconnection UI is shown
56
+ if !ok {
57
+ if let Some(window) = handle.get_webview_window("main") {
58
+ let _ = window.navigate("tauri://localhost".parse().unwrap());
59
+ }
60
+ }
61
+ }
62
+ }
63
+ });
64
+ }