a2acalling 0.6.50 → 0.6.51

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.
@@ -11,13 +11,14 @@ crate-type = ["lib", "cdylib", "staticlib"]
11
11
  tauri-build = { version = "2", features = [] }
12
12
 
13
13
  [dependencies]
14
- tauri = { version = "2", features = [] }
14
+ tauri = { version = "2", features = ["tray-icon"] }
15
15
  tauri-plugin-shell = "2"
16
16
  tauri-plugin-notification = "2"
17
17
  tauri-plugin-deep-link = "2"
18
18
  tauri-plugin-window-state = "2"
19
19
  serde = { version = "1", features = ["derive"] }
20
20
  serde_json = "1"
21
- reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
21
+ reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
22
22
  tokio = { version = "1", features = ["full"] }
23
23
  dirs = "6"
24
+ futures-util = "0.3"
@@ -110,8 +110,8 @@ pub fn run() {
110
110
  // Start background health monitor
111
111
  health::start_health_monitor(app.handle().clone());
112
112
 
113
- // Start notification poller
114
- notifications::start_notification_poller(app.handle().clone());
113
+ // Start server-driven event listener for native notifications.
114
+ notifications::start_event_stream_listener(app.handle().clone());
115
115
 
116
116
  // Menu bar tray icon
117
117
  let show = MenuItem::with_id(app, "show", "Show A2A Callbook", true, None::<&str>)?;
@@ -1,101 +1,180 @@
1
- use std::collections::HashSet;
2
- use std::sync::Mutex;
1
+ use futures_util::StreamExt;
2
+ use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
3
3
  use std::time::Duration;
4
4
  use tauri::Manager;
5
5
  use tauri_plugin_notification::NotificationExt;
6
6
 
7
- static SEEN_CALLS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
7
+ static LAST_EVENT_ID: AtomicU64 = AtomicU64::new(0);
8
+ static UNREAD_COUNT: AtomicUsize = AtomicUsize::new(0);
8
9
 
9
10
  #[derive(Debug, serde::Deserialize)]
10
- struct CallsResponse {
11
- success: bool,
12
- calls: Option<Vec<Conversation>>,
11
+ struct DashboardEvent {
12
+ id: Option<u64>,
13
+ #[serde(rename = "type")]
14
+ event_type: Option<String>,
15
+ payload: Option<serde_json::Value>,
13
16
  }
14
17
 
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>,
18
+ fn maybe_set_dock_badge(app: &tauri::AppHandle, count: usize) {
19
+ // Best effort: use the main window badge API. On macOS this maps to Dock badge label/count.
20
+ if let Some(window) = app.get_webview_window("main") {
21
+ let _ = window.set_badge_count(if count == 0 { None } else { Some(count as i64) });
22
+ }
23
+ }
24
+
25
+ fn show_notification(app: &tauri::AppHandle, title: &str, body: &str) {
26
+ let _ = app.notification().builder().title(title).body(body).show();
22
27
  }
23
28
 
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());
29
+ fn process_dashboard_event(app: &tauri::AppHandle, raw: &str) {
30
+ let parsed: DashboardEvent = match serde_json::from_str(raw) {
31
+ Ok(value) => value,
32
+ Err(_) => return,
33
+ };
34
+
35
+ if let Some(id) = parsed.id {
36
+ let current = LAST_EVENT_ID.load(Ordering::Relaxed);
37
+ if id > current {
38
+ LAST_EVENT_ID.store(id, Ordering::Relaxed);
39
+ }
40
+ }
41
+
42
+ let event_type = parsed.event_type.unwrap_or_default();
43
+ let payload = parsed.payload.unwrap_or_else(|| serde_json::json!({}));
44
+
45
+ if event_type == "call.inbound" {
46
+ let caller = payload
47
+ .get("caller_name")
48
+ .and_then(|v| v.as_str())
49
+ .unwrap_or("Unknown agent");
50
+ show_notification(
51
+ app,
52
+ &format!("Inbound call from {}", caller),
53
+ "Open A2A Callbook to respond.",
54
+ );
55
+ let unread = UNREAD_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
56
+ maybe_set_dock_badge(app, unread);
57
+ return;
30
58
  }
31
59
 
60
+ if event_type == "summary.completed" {
61
+ let contact = payload
62
+ .get("contact_name")
63
+ .and_then(|v| v.as_str())
64
+ .unwrap_or("conversation");
65
+ show_notification(
66
+ app,
67
+ "Summary complete",
68
+ &format!("Conversation with {} has a summary.", contact),
69
+ );
70
+ let unread = UNREAD_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
71
+ maybe_set_dock_badge(app, unread);
72
+ return;
73
+ }
74
+ }
75
+
76
+ /// Connect to server-driven dashboard SSE and map events to native notifications.
77
+ pub fn start_event_stream_listener(app: tauri::AppHandle) {
32
78
  tokio::spawn(async move {
33
- // Wait for initial server discovery
34
- tokio::time::sleep(Duration::from_secs(10)).await;
79
+ // Wait for initial discovery attempt.
80
+ tokio::time::sleep(Duration::from_secs(2)).await;
35
81
 
36
82
  loop {
37
- tokio::time::sleep(Duration::from_secs(15)).await;
38
-
83
+ if !crate::health::is_connected() {
84
+ tokio::time::sleep(Duration::from_secs(2)).await;
85
+ continue;
86
+ }
39
87
  let port = crate::health::current_port();
40
- if port == 0 || !crate::health::is_connected() {
88
+ if port == 0 {
89
+ tokio::time::sleep(Duration::from_secs(2)).await;
41
90
  continue;
42
91
  }
43
92
 
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 {
93
+ let url = format!("http://127.0.0.1:{}/api/a2a/dashboard/events?replay=50", port);
94
+ let client = match reqwest::Client::builder()
95
+ .timeout(Duration::from_secs(30))
96
+ .build()
97
+ {
54
98
  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,
99
+ Err(_) => {
100
+ tokio::time::sleep(Duration::from_secs(2)).await;
101
+ continue;
102
+ }
66
103
  };
67
104
 
68
- if !data.success {
69
- continue;
105
+ let mut req = client
106
+ .get(&url)
107
+ .header("Accept", "text/event-stream");
108
+ let last_id = LAST_EVENT_ID.load(Ordering::Relaxed);
109
+ if last_id > 0 {
110
+ req = req.header("Last-Event-ID", last_id.to_string());
70
111
  }
71
112
 
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) {
113
+ let response = match req.send().await {
114
+ Ok(resp) => resp,
115
+ Err(_) => {
116
+ tokio::time::sleep(Duration::from_secs(2)).await;
78
117
  continue;
79
118
  }
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();
119
+ };
120
+ if !response.status().is_success() {
121
+ tokio::time::sleep(Duration::from_secs(3)).await;
122
+ continue;
90
123
  }
91
124
 
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());
125
+ // On first connect we intentionally suppress replay notifications.
126
+ // IDs are still tracked so reconnects can resume reliably.
127
+ let mut suppress_replay_notifications = LAST_EVENT_ID.load(Ordering::Relaxed) == 0;
128
+ let mut stream = response.bytes_stream();
129
+ let mut buffer = String::new();
130
+ let mut data_lines: Vec<String> = Vec::new();
131
+
132
+ while let Some(chunk) = stream.next().await {
133
+ let bytes = match chunk {
134
+ Ok(value) => value,
135
+ Err(_) => break,
136
+ };
137
+ let piece = String::from_utf8_lossy(&bytes);
138
+ buffer.push_str(&piece);
139
+
140
+ while let Some(pos) = buffer.find('\n') {
141
+ let mut line: String = buffer.drain(..=pos).collect();
142
+ line = line.trim_end_matches('\n').trim_end_matches('\r').to_string();
143
+
144
+ if line.is_empty() {
145
+ if !data_lines.is_empty() {
146
+ let data = data_lines.join("\n");
147
+ if !suppress_replay_notifications {
148
+ process_dashboard_event(&app, &data);
149
+ }
150
+ data_lines.clear();
151
+ }
152
+ continue;
153
+ }
154
+ if line.starts_with(':') {
155
+ if line.starts_with(": connected") {
156
+ suppress_replay_notifications = false;
157
+ }
158
+ continue;
159
+ }
160
+ if let Some(rest) = line.strip_prefix("id:") {
161
+ let value = rest.trim();
162
+ if let Ok(parsed) = value.parse::<u64>() {
163
+ let current = LAST_EVENT_ID.load(Ordering::Relaxed);
164
+ if parsed > current {
165
+ LAST_EVENT_ID.store(parsed, Ordering::Relaxed);
166
+ }
167
+ }
168
+ continue;
169
+ }
170
+ if let Some(rest) = line.strip_prefix("data:") {
171
+ data_lines.push(rest.trim().to_string());
172
+ }
97
173
  }
98
174
  }
175
+
176
+ // Connection dropped: reconnect with jitter.
177
+ tokio::time::sleep(Duration::from_millis(900)).await;
99
178
  }
100
179
  });
101
180
  }
@@ -16,13 +16,7 @@
16
16
  "minHeight": 600,
17
17
  "resizable": true
18
18
  }
19
- ],
20
- "security": {
21
- "dangerousRemoteUrlAccess": [
22
- { "url": "http://127.0.0.1:**" },
23
- { "url": "http://localhost:**" }
24
- ]
25
- }
19
+ ]
26
20
  },
27
21
  "bundle": {
28
22
  "active": true,
@@ -30,8 +24,7 @@
30
24
  "icon": [
31
25
  "icons/32x32.png",
32
26
  "icons/128x128.png",
33
- "icons/128x128@2x.png",
34
- "icons/icon.icns"
27
+ "icons/128x128@2x.png"
35
28
  ],
36
29
  "macOS": {
37
30
  "minimumSystemVersion": "12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.50",
3
+ "version": "0.6.51",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -44,12 +44,10 @@ const result = spawnSync(process.execPath, [cliPath, 'quickstart'], {
44
44
  if (result.error) {
45
45
  // Don't fail the install — the agent will get onboarding when it runs `a2a`.
46
46
  installSkillFiles();
47
- installMacOSApp();
48
47
  process.exit(0);
49
48
  }
50
49
 
51
50
  installSkillFiles();
52
- installMacOSApp();
53
51
  process.exit(result.status || 0);
54
52
 
55
53
  // Best-effort: install Claude Code + Codex skills into the workspace
@@ -61,50 +59,3 @@ function installSkillFiles() {
61
59
  // Silent — skills can be installed later with `a2a skills`
62
60
  }
63
61
  }
64
-
65
- // Download and install the native macOS app from GitHub Releases
66
- function installMacOSApp() {
67
- const os = require('os');
68
- const fs = require('fs');
69
-
70
- if (os.platform() !== 'darwin') return;
71
-
72
- try {
73
- const version = require('../package.json').version;
74
- const appDir = path.join(os.homedir(), 'Applications');
75
- const appPath = path.join(appDir, 'A2A Callbook.app');
76
-
77
- // Skip if already installed at same version
78
- const plistPath = path.join(appPath, 'Contents', 'Info.plist');
79
- if (fs.existsSync(plistPath)) {
80
- try {
81
- const plist = fs.readFileSync(plistPath, 'utf8');
82
- if (plist.includes(version)) {
83
- return; // Same version already installed
84
- }
85
- } catch (_) {}
86
- }
87
-
88
- const tarUrl = `https://github.com/onthegonow/a2a_calling/releases/download/v${version}/A2A-Callbook-${version}.app.tar.gz`;
89
- const tmpFile = path.join(os.tmpdir(), `a2a-callbook-${version}.tar.gz`);
90
-
91
- // Download
92
- const { execFileSync } = require('child_process');
93
- execFileSync('curl', ['-sL', '-o', tmpFile, tarUrl], { timeout: 30000 });
94
-
95
- if (!fs.existsSync(tmpFile) || fs.statSync(tmpFile).size < 1000) {
96
- return; // Download failed or too small — skip silently
97
- }
98
-
99
- // Ensure ~/Applications exists
100
- fs.mkdirSync(appDir, { recursive: true });
101
-
102
- // Extract
103
- execFileSync('tar', ['-xzf', tmpFile, '-C', appDir], { timeout: 15000 });
104
-
105
- // Cleanup
106
- try { fs.unlinkSync(tmpFile); } catch (_) {}
107
- } catch (_) {
108
- // Silently fail — native app is optional
109
- }
110
- }
@@ -11,9 +11,17 @@ const state = {
11
11
  invites: [],
12
12
  logs: [],
13
13
  logStats: null,
14
- trace: null
14
+ trace: null,
15
+ realtime: {
16
+ connected: false,
17
+ lastEventId: null
18
+ }
15
19
  };
16
20
 
21
+ let dashboardEventSource = null;
22
+ let reconnectTimer = null;
23
+ let refreshTimer = null;
24
+
17
25
  function showNotice(message) {
18
26
  const el = document.getElementById('notice');
19
27
  el.textContent = message;
@@ -23,6 +31,143 @@ function showNotice(message) {
23
31
  }, 3500);
24
32
  }
25
33
 
34
+ function scheduleRealtimeRefresh() {
35
+ clearTimeout(refreshTimer);
36
+ refreshTimer = setTimeout(() => {
37
+ Promise.all([
38
+ loadContacts().catch(() => {}),
39
+ loadCalls().catch(() => {})
40
+ ]).catch(() => {});
41
+ }, 250);
42
+ }
43
+
44
+ function notifyRealtime(title, body) {
45
+ const safeTitle = String(title || '').trim();
46
+ if (!safeTitle) return;
47
+ const safeBody = String(body || '').trim();
48
+ if (typeof window.Notification === 'undefined') return;
49
+ if (Notification.permission === 'granted') {
50
+ try {
51
+ // In A2A.app WebView this maps to native macOS notifications.
52
+ new Notification(safeTitle, safeBody ? { body: safeBody } : undefined);
53
+ } catch (err) {
54
+ // Ignore notification errors.
55
+ }
56
+ return;
57
+ }
58
+ if (Notification.permission !== 'denied') {
59
+ Notification.requestPermission().catch(() => {});
60
+ }
61
+ }
62
+
63
+ function handleRealtimeEvent(eventData) {
64
+ const type = String(eventData?.type || '').trim();
65
+ const payload = eventData?.payload || {};
66
+ if (!type) return;
67
+
68
+ if (type === 'call.inbound') {
69
+ const caller = payload.caller_name || 'Unknown agent';
70
+ showNotice(`Inbound call: ${caller}`);
71
+ notifyRealtime(`Inbound call from ${caller}`, 'Open A2A Callbook to respond.');
72
+ scheduleRealtimeRefresh();
73
+ return;
74
+ }
75
+
76
+ if (type === 'summary.completed') {
77
+ const contact = payload.contact_name || 'conversation';
78
+ showNotice(`Summary complete: ${contact}`);
79
+ notifyRealtime('Summary complete', `Conversation with ${contact} has a summary.`);
80
+ scheduleRealtimeRefresh();
81
+ return;
82
+ }
83
+
84
+ if (type === 'contact.status.changed') {
85
+ const contactId = String(payload.contact_id || '');
86
+ const status = String(payload.status || '');
87
+ if (contactId && status) {
88
+ state.contacts = (state.contacts || []).map((contact) => {
89
+ if (String(contact.id) !== contactId) return contact;
90
+ return { ...contact, status };
91
+ });
92
+ renderContacts();
93
+ renderContactDetail();
94
+ } else {
95
+ scheduleRealtimeRefresh();
96
+ }
97
+ return;
98
+ }
99
+
100
+ if (type === 'invite.used') {
101
+ showNotice('Callbook install link used');
102
+ loadCallbookDevices().catch(() => {});
103
+ return;
104
+ }
105
+
106
+ if (type === 'call.updated') {
107
+ scheduleRealtimeRefresh();
108
+ return;
109
+ }
110
+ }
111
+
112
+ function connectRealtimeEvents() {
113
+ clearTimeout(reconnectTimer);
114
+ if (dashboardEventSource) {
115
+ dashboardEventSource.close();
116
+ dashboardEventSource = null;
117
+ }
118
+
119
+ const qs = new URLSearchParams();
120
+ if (state.realtime.lastEventId) {
121
+ qs.set('since', String(state.realtime.lastEventId));
122
+ }
123
+ const endpoint = `/api/a2a/dashboard/events${qs.toString() ? `?${qs.toString()}` : ''}`;
124
+ const source = new EventSource(endpoint);
125
+ dashboardEventSource = source;
126
+
127
+ source.onopen = () => {
128
+ state.realtime.connected = true;
129
+ };
130
+
131
+ source.onerror = () => {
132
+ state.realtime.connected = false;
133
+ if (dashboardEventSource === source) {
134
+ dashboardEventSource.close();
135
+ dashboardEventSource = null;
136
+ }
137
+ reconnectTimer = setTimeout(connectRealtimeEvents, 1500);
138
+ };
139
+
140
+ const onAnyEvent = (evt) => {
141
+ let payload = null;
142
+ try {
143
+ payload = evt?.data ? JSON.parse(evt.data) : null;
144
+ } catch (_) {
145
+ payload = null;
146
+ }
147
+ if (!payload || typeof payload !== 'object') return;
148
+ if (payload.id) {
149
+ state.realtime.lastEventId = String(payload.id);
150
+ } else if (evt?.lastEventId) {
151
+ state.realtime.lastEventId = String(evt.lastEventId);
152
+ }
153
+ handleRealtimeEvent(payload);
154
+ };
155
+
156
+ source.onmessage = onAnyEvent;
157
+ source.addEventListener('call.inbound', onAnyEvent);
158
+ source.addEventListener('call.updated', onAnyEvent);
159
+ source.addEventListener('summary.completed', onAnyEvent);
160
+ source.addEventListener('invite.used', onAnyEvent);
161
+ source.addEventListener('contact.status.changed', onAnyEvent);
162
+ }
163
+
164
+ window.addEventListener('beforeunload', () => {
165
+ if (dashboardEventSource) {
166
+ dashboardEventSource.close();
167
+ dashboardEventSource = null;
168
+ }
169
+ });
170
+
26
171
  async function request(path, options = {}) {
27
172
  const res = await fetch(`/api/a2a/dashboard${path}`, {
28
173
  headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
@@ -1365,6 +1510,7 @@ async function bootstrap() {
1365
1510
  loadLogs()
1366
1511
  ]);
1367
1512
  showNotice('Dashboard loaded');
1513
+ connectRealtimeEvents();
1368
1514
 
1369
1515
  setInterval(() => {
1370
1516
  loadAutoUpdateStatus().catch(() => {});
@@ -18,9 +18,10 @@ const DB_FILENAME = 'a2a-conversations.db';
18
18
  const logger = createLogger({ component: 'a2a.conversations' });
19
19
 
20
20
  class ConversationStore {
21
- constructor(configDir = DEFAULT_CONFIG_DIR) {
21
+ constructor(configDir = DEFAULT_CONFIG_DIR, options = {}) {
22
22
  this.configDir = configDir;
23
23
  this.dbPath = path.join(configDir, DB_FILENAME);
24
+ this.eventStore = options.eventStore || null;
24
25
  this.db = null;
25
26
  this._ensureDir();
26
27
  }
@@ -253,6 +254,19 @@ class ConversationStore {
253
254
  VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
254
255
  `).run(id, contactId, contactName, tokenId, direction, now, now);
255
256
 
257
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
258
+ this.eventStore.emitEvent('call.updated', {
259
+ conversation_id: id,
260
+ status: 'active',
261
+ direction,
262
+ contact_id: contactId || null,
263
+ contact_name: contactName || null
264
+ }, {
265
+ conversationId: id,
266
+ contactId: contactId || null
267
+ });
268
+ }
269
+
256
270
  return { id, resumed: false };
257
271
  }
258
272
 
@@ -284,6 +298,14 @@ class ConversationStore {
284
298
  WHERE id = ?
285
299
  `).run(now, conversationId);
286
300
 
301
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
302
+ this.eventStore.emitEvent('call.updated', {
303
+ conversation_id: conversationId,
304
+ status: 'active',
305
+ direction
306
+ }, { conversationId });
307
+ }
308
+
287
309
  return { id, timestamp: now };
288
310
  }
289
311
 
@@ -450,6 +472,31 @@ class ConversationStore {
450
472
  `).run(now, conversationId);
451
473
  }
452
474
 
475
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
476
+ this.eventStore.emitEvent('call.updated', {
477
+ conversation_id: conversationId,
478
+ status: 'concluded',
479
+ contact_id: conversation.contact_id || null,
480
+ contact_name: conversation.contact_name || null
481
+ }, {
482
+ conversationId,
483
+ contactId: conversation.contact_id || null
484
+ });
485
+
486
+ if (summary || ownerSummary) {
487
+ this.eventStore.emitEvent('summary.completed', {
488
+ conversation_id: conversationId,
489
+ contact_id: conversation.contact_id || null,
490
+ contact_name: conversation.contact_name || null,
491
+ has_summary: Boolean(summary),
492
+ has_owner_summary: Boolean(ownerSummary)
493
+ }, {
494
+ conversationId,
495
+ contactId: conversation.contact_id || null
496
+ });
497
+ }
498
+ }
499
+
453
500
  return {
454
501
  success: true,
455
502
  conversationId,
@@ -472,6 +519,13 @@ class ConversationStore {
472
519
  WHERE id = ?
473
520
  `).run(now, conversationId);
474
521
 
522
+ if (this.eventStore && this.eventStore.isAvailable && this.eventStore.isAvailable()) {
523
+ this.eventStore.emitEvent('call.updated', {
524
+ conversation_id: conversationId,
525
+ status: 'timeout'
526
+ }, { conversationId });
527
+ }
528
+
475
529
  return { success: true };
476
530
  }
477
531