a2acalling 0.6.49 → 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.49",
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": {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A2A Skill Installer
3
+ *
4
+ * Copies Claude Code commands and Codex AGENTS.md into a target project directory.
5
+ * Idempotent: skips files that already exist with identical content.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const PACKAGE_ROOT = path.join(__dirname, '..');
12
+
13
+ const SKILL_FILES = [
14
+ { src: '.claude/commands/a2a-call.md', dest: '.claude/commands/a2a-call.md' },
15
+ { src: '.claude/commands/a2a-invite.md', dest: '.claude/commands/a2a-invite.md' },
16
+ { src: '.claude/commands/a2a-contacts.md', dest: '.claude/commands/a2a-contacts.md' },
17
+ { src: '.claude/commands/a2a-status.md', dest: '.claude/commands/a2a-status.md' },
18
+ { src: '.claude/commands/a2a-setup.md', dest: '.claude/commands/a2a-setup.md' },
19
+ { src: '.codex/AGENTS.md', dest: '.codex/AGENTS.md' }
20
+ ];
21
+
22
+ function installSkills(targetDir, options = {}) {
23
+ const result = { installed: [], skipped: [], errors: [] };
24
+
25
+ for (const file of SKILL_FILES) {
26
+ const srcPath = path.join(PACKAGE_ROOT, file.src);
27
+ const destPath = path.join(targetDir, file.dest);
28
+
29
+ try {
30
+ if (!fs.existsSync(srcPath)) {
31
+ result.errors.push({ file: file.src, error: 'Source file not found' });
32
+ continue;
33
+ }
34
+
35
+ const srcContent = fs.readFileSync(srcPath, 'utf8');
36
+
37
+ // Check if identical file already exists
38
+ if (!options.force && fs.existsSync(destPath)) {
39
+ const existing = fs.readFileSync(destPath, 'utf8');
40
+ if (existing === srcContent) {
41
+ result.skipped.push(file.dest);
42
+ continue;
43
+ }
44
+ }
45
+
46
+ // Create directory and write file
47
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
48
+ fs.writeFileSync(destPath, srcContent);
49
+ result.installed.push(file.dest);
50
+ } catch (err) {
51
+ result.errors.push({ file: file.dest, error: err.message });
52
+ }
53
+ }
54
+
55
+ return result;
56
+ }
57
+
58
+ // CLI mode: node scripts/install-skills.js [targetDir] [--force]
59
+ if (require.main === module) {
60
+ const args = process.argv.slice(2);
61
+ const force = args.includes('--force');
62
+ const targetDir = args.find(a => !a.startsWith('-')) || process.cwd();
63
+
64
+ const result = installSkills(targetDir, { force });
65
+
66
+ if (result.installed.length) {
67
+ console.log(`Installed ${result.installed.length} A2A skill file(s):`);
68
+ result.installed.forEach(f => console.log(` + ${f}`));
69
+ }
70
+ if (result.skipped.length) {
71
+ console.log(`Skipped ${result.skipped.length} unchanged file(s)`);
72
+ }
73
+ if (result.errors.length) {
74
+ console.error(`Errors: ${result.errors.length}`);
75
+ result.errors.forEach(e => console.error(` ! ${e.file}: ${e.error}`));
76
+ process.exit(1);
77
+ }
78
+ }
79
+
80
+ module.exports = { installSkills, SKILL_FILES };
@@ -43,56 +43,19 @@ const result = spawnSync(process.execPath, [cliPath, 'quickstart'], {
43
43
 
44
44
  if (result.error) {
45
45
  // Don't fail the install — the agent will get onboarding when it runs `a2a`.
46
- installMacOSApp();
46
+ installSkillFiles();
47
47
  process.exit(0);
48
48
  }
49
49
 
50
- installMacOSApp();
50
+ installSkillFiles();
51
51
  process.exit(result.status || 0);
52
52
 
53
- // Download and install the native macOS app from GitHub Releases
54
- function installMacOSApp() {
55
- const os = require('os');
56
- const fs = require('fs');
57
-
58
- if (os.platform() !== 'darwin') return;
59
-
53
+ // Best-effort: install Claude Code + Codex skills into the workspace
54
+ function installSkillFiles() {
60
55
  try {
61
- const version = require('../package.json').version;
62
- const appDir = path.join(os.homedir(), 'Applications');
63
- const appPath = path.join(appDir, 'A2A Callbook.app');
64
-
65
- // Skip if already installed at same version
66
- const plistPath = path.join(appPath, 'Contents', 'Info.plist');
67
- if (fs.existsSync(plistPath)) {
68
- try {
69
- const plist = fs.readFileSync(plistPath, 'utf8');
70
- if (plist.includes(version)) {
71
- return; // Same version already installed
72
- }
73
- } catch (_) {}
74
- }
75
-
76
- const tarUrl = `https://github.com/onthegonow/a2a_calling/releases/download/v${version}/A2A-Callbook-${version}.app.tar.gz`;
77
- const tmpFile = path.join(os.tmpdir(), `a2a-callbook-${version}.tar.gz`);
78
-
79
- // Download
80
- const { execFileSync } = require('child_process');
81
- execFileSync('curl', ['-sL', '-o', tmpFile, tarUrl], { timeout: 30000 });
82
-
83
- if (!fs.existsSync(tmpFile) || fs.statSync(tmpFile).size < 1000) {
84
- return; // Download failed or too small — skip silently
85
- }
86
-
87
- // Ensure ~/Applications exists
88
- fs.mkdirSync(appDir, { recursive: true });
89
-
90
- // Extract
91
- execFileSync('tar', ['-xzf', tmpFile, '-C', appDir], { timeout: 15000 });
92
-
93
- // Cleanup
94
- try { fs.unlinkSync(tmpFile); } catch (_) {}
95
- } catch (_) {
96
- // Silently fail — native app is optional
56
+ const { installSkills } = require('./install-skills');
57
+ installSkills(initCwd);
58
+ } catch (e) {
59
+ // Silent — skills can be installed later with `a2a skills`
97
60
  }
98
61
  }
@@ -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(() => {});