a2acalling 0.6.44 → 0.6.45

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,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
+ }
@@ -0,0 +1,185 @@
1
+ use tauri::{Manager, RunEvent, WindowEvent};
2
+ use tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem, AboutMetadata};
3
+ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
4
+ use tauri_plugin_deep_link::DeepLinkExt;
5
+
6
+ mod discovery;
7
+ mod health;
8
+ mod notifications;
9
+ mod server;
10
+
11
+ #[tauri::command]
12
+ async fn discover_server() -> Result<discovery::DiscoveryResult, String> {
13
+ let result = discovery::discover_server().await;
14
+ if let Some(port) = result.port {
15
+ health::set_connected(port);
16
+ }
17
+ Ok(result)
18
+ }
19
+
20
+ #[tauri::command]
21
+ fn start_server() -> Result<server::StartResult, String> {
22
+ Ok(server::start_server())
23
+ }
24
+
25
+ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
26
+ let about = PredefinedMenuItem::about(app, Some("About A2A Callbook"), Some(AboutMetadata {
27
+ name: Some("A2A Callbook".into()),
28
+ version: Some(env!("CARGO_PKG_VERSION").into()),
29
+ ..Default::default()
30
+ }))?;
31
+ let quit = PredefinedMenuItem::quit(app, Some("Quit A2A Callbook"))?;
32
+ let hide = PredefinedMenuItem::hide(app, Some("Hide A2A Callbook"))?;
33
+ let preferences = MenuItem::with_id(app, "preferences", "Preferences\u{2026}", true, Some("CmdOrCtrl+,"))?;
34
+ let separator = PredefinedMenuItem::separator(app)?;
35
+
36
+ let app_menu = Submenu::with_items(app, "A2A Callbook", true, &[
37
+ &about, &separator, &preferences, &separator, &hide, &separator, &quit,
38
+ ])?;
39
+
40
+ // View menu with tab shortcuts
41
+ let contacts = MenuItem::with_id(app, "tab-contacts", "Contacts", true, Some("CmdOrCtrl+1"))?;
42
+ let calls = MenuItem::with_id(app, "tab-calls", "Calls", true, Some("CmdOrCtrl+2"))?;
43
+ let logs = MenuItem::with_id(app, "tab-logs", "Logs", true, Some("CmdOrCtrl+3"))?;
44
+ let settings = MenuItem::with_id(app, "tab-settings", "Settings", true, Some("CmdOrCtrl+4"))?;
45
+ let invites = MenuItem::with_id(app, "tab-invites", "Invites", true, Some("CmdOrCtrl+5"))?;
46
+ let sep2 = PredefinedMenuItem::separator(app)?;
47
+ let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, Some("CmdOrCtrl+R"))?;
48
+
49
+ let view_menu = Submenu::with_items(app, "View", true, &[
50
+ &contacts, &calls, &logs, &settings, &invites, &sep2, &refresh,
51
+ ])?;
52
+
53
+ // Edit menu (standard macOS)
54
+ let copy = PredefinedMenuItem::copy(app, None)?;
55
+ let paste = PredefinedMenuItem::paste(app, None)?;
56
+ let cut = PredefinedMenuItem::cut(app, None)?;
57
+ let select_all = PredefinedMenuItem::select_all(app, None)?;
58
+ let edit_menu = Submenu::with_items(app, "Edit", true, &[
59
+ &cut, &copy, &paste, &select_all,
60
+ ])?;
61
+
62
+ let window_menu = Submenu::with_items(app, "Window", true, &[
63
+ &PredefinedMenuItem::minimize(app, None)?,
64
+ &PredefinedMenuItem::close_window(app, Some("Hide Window"))?,
65
+ ])?;
66
+
67
+ Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu, &window_menu])
68
+ }
69
+
70
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
71
+ pub fn run() {
72
+ let app = tauri::Builder::default()
73
+ .plugin(tauri_plugin_shell::init())
74
+ .plugin(tauri_plugin_notification::init())
75
+ .plugin(tauri_plugin_deep_link::init())
76
+ .plugin(tauri_plugin_window_state::Builder::new().build())
77
+ .invoke_handler(tauri::generate_handler![discover_server, start_server])
78
+ .setup(|app| {
79
+ let menu = build_menu(app.handle())?;
80
+ app.set_menu(menu)?;
81
+
82
+ // Handle menu events
83
+ let app_handle = app.handle().clone();
84
+ app.on_menu_event(move |_app, event| {
85
+ let id = event.id().0.as_str();
86
+ let tab = match id {
87
+ "tab-contacts" => Some("contacts"),
88
+ "tab-calls" => Some("calls"),
89
+ "tab-logs" => Some("logs"),
90
+ "tab-settings" => Some("settings"),
91
+ "tab-invites" => Some("invites"),
92
+ "preferences" => Some("settings"),
93
+ "refresh" => {
94
+ if let Some(window) = app_handle.get_webview_window("main") {
95
+ let _ = window.eval("window.location.reload()");
96
+ }
97
+ None
98
+ }
99
+ _ => None,
100
+ };
101
+
102
+ if let Some(tab_name) = tab {
103
+ if let Some(window) = app_handle.get_webview_window("main") {
104
+ let js = format!("window.location.hash = '{}'", tab_name);
105
+ let _ = window.eval(&js);
106
+ }
107
+ }
108
+ });
109
+
110
+ // Start background health monitor
111
+ health::start_health_monitor(app.handle().clone());
112
+
113
+ // Start notification poller
114
+ notifications::start_notification_poller(app.handle().clone());
115
+
116
+ // Menu bar tray icon
117
+ let show = MenuItem::with_id(app, "show", "Show A2A Callbook", true, None::<&str>)?;
118
+ let tray_quit = MenuItem::with_id(app, "tray-quit", "Quit", true, None::<&str>)?;
119
+ let tray_menu = Menu::with_items(app, &[&show, &tray_quit])?;
120
+
121
+ let _tray = TrayIconBuilder::new()
122
+ .tooltip("A2A Callbook")
123
+ .menu(&tray_menu)
124
+ .on_menu_event(|app, event| {
125
+ match event.id().0.as_str() {
126
+ "show" => {
127
+ if let Some(window) = app.get_webview_window("main") {
128
+ let _ = window.show();
129
+ let _ = window.set_focus();
130
+ }
131
+ }
132
+ "tray-quit" => {
133
+ app.exit(0);
134
+ }
135
+ _ => {}
136
+ }
137
+ })
138
+ .on_tray_icon_event(|tray, event| {
139
+ if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
140
+ let app = tray.app_handle();
141
+ if let Some(window) = app.get_webview_window("main") {
142
+ let _ = window.show();
143
+ let _ = window.set_focus();
144
+ }
145
+ }
146
+ })
147
+ .build(app)?;
148
+
149
+ // Handle a2a:// deep links
150
+ let deep_link_handle = app.handle().clone();
151
+ app.deep_link().on_open_url(move |event| {
152
+ let urls = event.urls();
153
+ for url in urls {
154
+ let url_str = url.to_string();
155
+ // a2a://host/callbook/CODE or a2a://host/fed_TOKEN
156
+ if let Some(window) = deep_link_handle.get_webview_window("main") {
157
+ let _ = window.show();
158
+ let _ = window.set_focus();
159
+ // Pass URL to the SPA via JS
160
+ let js = format!(
161
+ "window.__A2A_DEEP_LINK = '{}'; \
162
+ window.dispatchEvent(new CustomEvent('a2a-deep-link', {{ detail: '{}' }}))",
163
+ url_str.replace('\\', "\\\\").replace('\'', "\\'"),
164
+ url_str.replace('\\', "\\\\").replace('\'', "\\'")
165
+ );
166
+ let _ = window.eval(&js);
167
+ }
168
+ }
169
+ });
170
+
171
+ Ok(())
172
+ })
173
+ .build(tauri::generate_context!())
174
+ .expect("error building A2A Callbook");
175
+
176
+ // Cmd+W hides window instead of quitting
177
+ app.run(|app_handle, event| {
178
+ if let RunEvent::WindowEvent { label, event: WindowEvent::CloseRequested { api, .. }, .. } = &event {
179
+ api.prevent_close();
180
+ if let Some(window) = app_handle.get_webview_window(label) {
181
+ let _ = window.hide();
182
+ }
183
+ }
184
+ });
185
+ }
@@ -0,0 +1,6 @@
1
+ // Prevents additional console window on Windows in release
2
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
+
4
+ fn main() {
5
+ a2a_callbook_lib::run()
6
+ }
@@ -0,0 +1,101 @@
1
+ use std::collections::HashSet;
2
+ use std::sync::Mutex;
3
+ use std::time::Duration;
4
+ use tauri::Manager;
5
+ use tauri_plugin_notification::NotificationExt;
6
+
7
+ static SEEN_CALLS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
8
+
9
+ #[derive(Debug, serde::Deserialize)]
10
+ struct CallsResponse {
11
+ success: bool,
12
+ calls: Option<Vec<Conversation>>,
13
+ }
14
+
15
+ #[derive(Debug, serde::Deserialize)]
16
+ struct Conversation {
17
+ id: String,
18
+ contact_name: Option<String>,
19
+ summary: Option<String>,
20
+ status: Option<String>,
21
+ started_at: Option<String>,
22
+ }
23
+
24
+ /// Poll for new inbound calls and fire native notifications
25
+ pub fn start_notification_poller(app: tauri::AppHandle) {
26
+ // Initialize seen set
27
+ {
28
+ let mut seen = SEEN_CALLS.lock().unwrap();
29
+ *seen = Some(HashSet::new());
30
+ }
31
+
32
+ tokio::spawn(async move {
33
+ // Wait for initial server discovery
34
+ tokio::time::sleep(Duration::from_secs(10)).await;
35
+
36
+ loop {
37
+ tokio::time::sleep(Duration::from_secs(15)).await;
38
+
39
+ let port = crate::health::current_port();
40
+ if port == 0 || !crate::health::is_connected() {
41
+ continue;
42
+ }
43
+
44
+ let url = format!(
45
+ "http://127.0.0.1:{}/api/a2a/dashboard/calls?status=active",
46
+ port
47
+ );
48
+
49
+ let client = reqwest::Client::builder()
50
+ .timeout(Duration::from_secs(5))
51
+ .build();
52
+
53
+ let client = match client {
54
+ Ok(c) => c,
55
+ Err(_) => continue,
56
+ };
57
+
58
+ let resp = match client.get(&url).send().await {
59
+ Ok(r) => r,
60
+ Err(_) => continue,
61
+ };
62
+
63
+ let data: CallsResponse = match resp.json().await {
64
+ Ok(d) => d,
65
+ Err(_) => continue,
66
+ };
67
+
68
+ if !data.success {
69
+ continue;
70
+ }
71
+
72
+ let conversations = data.calls.unwrap_or_default();
73
+ let mut seen = SEEN_CALLS.lock().unwrap();
74
+ let seen_set = seen.as_mut().unwrap();
75
+
76
+ for conv in &conversations {
77
+ if seen_set.contains(&conv.id) {
78
+ continue;
79
+ }
80
+ seen_set.insert(conv.id.clone());
81
+
82
+ let caller = conv.contact_name.as_deref().unwrap_or("Unknown agent");
83
+ let summary = conv.summary.as_deref().unwrap_or("New inbound call");
84
+
85
+ let _ = app.notification()
86
+ .builder()
87
+ .title(&format!("Inbound call from {}", caller))
88
+ .body(summary)
89
+ .show();
90
+ }
91
+
92
+ // Prevent unbounded memory growth — cap at 1000 entries
93
+ if seen_set.len() > 1000 {
94
+ seen_set.clear();
95
+ for conv in &conversations {
96
+ seen_set.insert(conv.id.clone());
97
+ }
98
+ }
99
+ }
100
+ });
101
+ }