@agenticmail/cli 0.9.8 → 0.9.9

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,7 +11,7 @@
11
11
  // arrive at the heartbeat cadence (30 s) so the badge text
12
12
  // reflects what the agent is doing right now.
13
13
 
14
- import { state, API_URL } from './state.js';
14
+ import { onSystemEvent } from './system-stream.js';
15
15
 
16
16
  const BADGE_CONTAINER_ID = 'activity-badges';
17
17
 
@@ -21,7 +21,9 @@ const BADGE_CONTAINER_ID = 'activity-badges';
21
21
  * the badge container on every event.
22
22
  */
23
23
  const workers = new Map();
24
- let sseController = null;
24
+ let unsubWorkerStarted = null;
25
+ let unsubWorkerHeartbeat = null;
26
+ let unsubWorkerFinished = null;
25
27
 
26
28
  /**
27
29
  * Map an SDK tool name (or the truncated head we capture in
@@ -91,36 +93,16 @@ function handleEvent(event) {
91
93
  }
92
94
 
93
95
  /**
94
- * Subscribe to /system/events with the master key. The web UI
95
- * already holds the master key in state.masterKey (set on
96
- * sign-in). Re-subscribes idempotently — safe to call after
97
- * agent-list refresh.
96
+ * Subscribe to worker_* events on the shared /system/events stream.
97
+ * Idempotent safe to call after agent-list refresh.
98
98
  */
99
99
  export function subscribeToActivity() {
100
- if (sseController) { try { sseController.abort(); } catch {} }
101
- sseController = new AbortController();
102
- fetch(`${API_URL}/api/agenticmail/system/events`, {
103
- headers: { Authorization: `Bearer ${state.masterKey}`, Accept: 'text/event-stream' },
104
- signal: sseController.signal,
105
- }).then(async res => {
106
- if (!res.ok || !res.body) return;
107
- const reader = res.body.getReader();
108
- const dec = new TextDecoder();
109
- let buf = '';
110
- while (!sseController.signal.aborted) {
111
- const { done, value } = await reader.read();
112
- if (done) break;
113
- buf += dec.decode(value, { stream: true });
114
- let i;
115
- while ((i = buf.indexOf('\n\n')) !== -1) {
116
- const frame = buf.slice(0, i); buf = buf.slice(i + 2);
117
- for (const line of frame.split('\n')) {
118
- if (!line.startsWith('data: ')) continue;
119
- try { handleEvent(JSON.parse(line.slice(6))); } catch {}
120
- }
121
- }
122
- }
123
- }).catch(() => { /* dropped — user can refresh to reconnect */ });
100
+ if (unsubWorkerStarted) { try { unsubWorkerStarted(); } catch {} }
101
+ if (unsubWorkerHeartbeat) { try { unsubWorkerHeartbeat(); } catch {} }
102
+ if (unsubWorkerFinished) { try { unsubWorkerFinished(); } catch {} }
103
+ unsubWorkerStarted = onSystemEvent('worker_started', handleEvent);
104
+ unsubWorkerHeartbeat = onSystemEvent('worker_heartbeat', handleEvent);
105
+ unsubWorkerFinished = onSystemEvent('worker_finished', handleEvent);
124
106
  }
125
107
 
126
108
  // Tiny HTML escapers (kept local to avoid an import cycle).
@@ -14,6 +14,7 @@ import { loadList, renderList, clearSearch, ensureFolderCache } from './list-vie
14
14
  import { openMessage } from './message-view.js';
15
15
  import { populateComposeFrom, openCompose, openDraft, closeCompose, discardCompose, sendCompose } from './compose.js';
16
16
  import { subscribeToAllAgents, maybeRequestNotificationPermission } from './sse.js';
17
+ import { connectSystemStream } from './system-stream.js';
17
18
  import { subscribeToActivity } from './activity-badges.js';
18
19
  import { icon } from './icons.js';
19
20
  import { isSoundEnabled, setSoundEnabled, playNotificationSound } from './sound.js';
@@ -111,12 +112,14 @@ async function bootstrap() {
111
112
  if (initial) await selectAgent(initial);
112
113
  renderProfile();
113
114
  populateComposeFrom();
114
- subscribeToAllAgents();
115
- // Real-time worker activity badges. Master-key-scoped SSE on
116
- // /system/events; the dispatcher's worker_started /
117
- // worker_heartbeat / worker_finished events drive the badge
118
- // rendering. Idempotent safe to call after bootstrap reruns.
119
- subscribeToActivity();
115
+ // ONE shared SSE connection on /system/events for the whole UI.
116
+ // Used to be N+1 (one per agent for new mail + one for activity
117
+ // badges), which saturated the browser's 6-connections-per-origin
118
+ // cap with 5 agents and blocked page navigation. Now everything
119
+ // multiplexes through this single stream see system-stream.js.
120
+ connectSystemStream();
121
+ subscribeToAllAgents(); // new_mail handlers
122
+ subscribeToActivity(); // worker_* handlers
120
123
  maybeRequestNotificationPermission();
121
124
  // If the URL points at a message (not a folder), open it now —
122
125
  // the folder list selectAgent already loaded stays in the
@@ -1,47 +1,35 @@
1
- // Real-time mail delivery via Server-Sent Events. Every agent gets
2
- // its own subscription; the dispatcher pushes a `new` event per
3
- // arrived message. We fan that out to:
4
- // 1. List view silent in-place refresh (no flicker, no scroll
5
- // jump, no bulk-selection wipe) if it's the active inbox
6
- // 2. Profile dropdownbump the per-agent unread counter
7
- // 3. Browser notification — system ping when the tab is in the background
8
- // 4. Soft chime (toggleable) when sound is enabled
9
- import { state, API_URL } from './state.js';
1
+ // New-mail notifications for the web UI.
2
+ //
3
+ // Listens for `new_mail` events on the shared /system/events stream
4
+ // (one connection for the whole UI; see system-stream.js for why).
5
+ // Fans the event out to:
6
+ // 1. List viewsilent in-place refresh (no flicker / scroll jump)
7
+ // if it's the active inbox.
8
+ // 2. Profile dropdown bump the per-agent unread counter.
9
+ // 3. Browser notification when tab isn't focused.
10
+ // 4. Soft chime (toggleable) when sound is enabled.
11
+
12
+ import { state } from './state.js';
10
13
  import { toast } from './utils.js';
11
14
  import { renderProfile } from './profile.js';
12
15
  import { silentRefresh } from './list-view.js';
13
16
  import { playNotificationSound } from './sound.js';
17
+ import { onSystemEvent } from './system-stream.js';
18
+
19
+ let unsubscribe = null;
14
20
 
21
+ /**
22
+ * Wire the new-mail listener onto the shared system stream.
23
+ * Idempotent — safe to call after agent-list refreshes.
24
+ */
15
25
  export function subscribeToAllAgents() {
16
- // Tear down previous controllers (called on agent-list refresh).
17
- for (const c of state.sseControllers) { try { c.abort(); } catch {} }
18
- state.sseControllers = [];
19
- for (const agent of state.agents) {
20
- const ctrl = new AbortController();
21
- state.sseControllers.push(ctrl);
22
- fetch(`${API_URL}/api/agenticmail/events`, {
23
- headers: { Authorization: `Bearer ${agent.apiKey}`, Accept: 'text/event-stream' },
24
- signal: ctrl.signal,
25
- }).then(async res => {
26
- if (!res.ok || !res.body) return;
27
- const reader = res.body.getReader();
28
- const dec = new TextDecoder();
29
- let buf = '';
30
- while (!ctrl.signal.aborted) {
31
- const { done, value } = await reader.read();
32
- if (done) break;
33
- buf += dec.decode(value, { stream: true });
34
- let i;
35
- while ((i = buf.indexOf('\n\n')) !== -1) {
36
- const frame = buf.slice(0, i); buf = buf.slice(i + 2);
37
- for (const line of frame.split('\n')) {
38
- if (!line.startsWith('data: ')) continue;
39
- try { handleSseEvent(agent, JSON.parse(line.slice(6))); } catch {}
40
- }
41
- }
42
- }
43
- }).catch(() => {});
44
- }
26
+ if (unsubscribe) { try { unsubscribe(); } catch {} }
27
+ unsubscribe = onSystemEvent('new_mail', payload => {
28
+ // payload shape: { type: 'new_mail', agentId, agentName, event }
29
+ const agent = state.agents.find(a => a.id === payload.agentId);
30
+ if (!agent) return; // unknown agent (account_deleted race)
31
+ handleSseEvent(agent, payload.event);
32
+ });
45
33
  }
46
34
 
47
35
  async function handleSseEvent(agent, event) {
@@ -52,23 +40,12 @@ async function handleSseEvent(agent, event) {
52
40
 
53
41
  const isOpen = state.selectedAgent?.id === agent.id;
54
42
  if (isOpen) {
55
- // Silent in-place refresh — re-fetches the list digest and
56
- // re-renders ONLY the rows div. Toolbar (select-all, refresh,
57
- // bulk-actions) is untouched; existing row checkboxes survive;
58
- // scroll position is preserved by the browser since we replace
59
- // only the inner content. No "Loading…" flicker.
60
43
  await silentRefresh(agent, state.selectedFolder);
61
- state.unread[agent.id] = 0; // user is looking — clear badge
44
+ state.unread[agent.id] = 0;
62
45
  renderProfile();
63
46
  }
64
47
 
65
- // Soft chime — respects the user's sound toggle. Plays for every
66
- // arrival regardless of whether the tab is focused, because that
67
- // is the whole point of the chime (a foregrounded tab still
68
- // benefits from the audible ping when the user's attention is
69
- // elsewhere on screen).
70
48
  playNotificationSound();
71
-
72
49
  fireBrowserNotification(agent, event, isOpen);
73
50
 
74
51
  if (!isOpen) {
@@ -103,8 +80,6 @@ function fireBrowserNotification(agent, event, isOpen) {
103
80
  });
104
81
  n.onclick = () => {
105
82
  window.focus();
106
- // Switching agent here requires the router; let the user click
107
- // through manually so we don't tightly couple sse → router.
108
83
  if (event.uid) location.hash = `#/m/${event.uid}`;
109
84
  n.close();
110
85
  };
@@ -10,7 +10,6 @@ export const state = {
10
10
  currentMessage: null,
11
11
  composeReplyContext: null,
12
12
  searchQuery: '',
13
- sseControllers: [],
14
13
  unread: {}, // { [agentId]: count }
15
14
  /**
16
15
  * Mapping from sidebar folder id ('sent', 'drafts', 'spam', etc.)
@@ -0,0 +1,109 @@
1
+ // Single shared SSE connection to /system/events.
2
+ //
3
+ // # Why this exists
4
+ //
5
+ // Browsers cap HTTP connections at 6 per origin. The old web UI opened
6
+ // ONE per-agent /events SSE plus ONE /system/events SSE — so with 5
7
+ // agents, that's 6 long-lived connections, exhausting the cap. Every
8
+ // other request (page refresh, message fetch, attachment download)
9
+ // had to wait for an SSE slot to free up, which never happened
10
+ // because they're persistent.
11
+ //
12
+ // Fix: every per-agent new-mail event is now also pushed to
13
+ // /system/events by the API. The UI subscribes ONCE here, and modules
14
+ // register handlers via `onSystemEvent(type, handler)`. Net effect:
15
+ // 6 SSE connections → 1, freeing 5 slots for actual HTTP traffic.
16
+ //
17
+ // # API
18
+ //
19
+ // import { connectSystemStream, onSystemEvent } from './system-stream.js';
20
+ // connectSystemStream(); // wire it up once after sign-in
21
+ // onSystemEvent('new_mail', (e) => { ... }); // subscribe to event type
22
+ // onSystemEvent('worker_started', (e) => {}); // ANY type the server emits
23
+ //
24
+ // Multiple subscribers per type are supported. Each handler runs in
25
+ // try/catch so one buggy handler can't kill the others.
26
+
27
+ import { state, API_URL } from './state.js';
28
+
29
+ let controller = null;
30
+ let connected = false;
31
+ const handlers = new Map(); // type → Set<handler>
32
+
33
+ export function onSystemEvent(type, handler) {
34
+ if (!handlers.has(type)) handlers.set(type, new Set());
35
+ handlers.get(type).add(handler);
36
+ return () => handlers.get(type)?.delete(handler);
37
+ }
38
+
39
+ function dispatch(event) {
40
+ if (!event || typeof event !== 'object') return;
41
+ const set = handlers.get(event.type);
42
+ if (!set) return;
43
+ for (const h of set) {
44
+ try { h(event); } catch (err) { console.error('[system-stream] handler error', err); }
45
+ }
46
+ }
47
+
48
+ export function connectSystemStream() {
49
+ if (controller) { try { controller.abort(); } catch {} }
50
+ controller = new AbortController();
51
+ connected = false;
52
+ const sig = controller.signal;
53
+
54
+ // Auto-reconnect with exponential backoff. Capped at 30s — keeping
55
+ // a UI live during a long server outage shouldn't slam the API
56
+ // every two seconds.
57
+ let backoff = 1000;
58
+ const loop = async () => {
59
+ while (!sig.aborted) {
60
+ try {
61
+ const res = await fetch(`${API_URL}/api/agenticmail/system/events`, {
62
+ headers: { Authorization: `Bearer ${state.masterKey}`, Accept: 'text/event-stream' },
63
+ signal: sig,
64
+ });
65
+ if (!res.ok || !res.body) {
66
+ // Hard 4xx (auth) → stop trying; user has to refresh / sign in again.
67
+ if (res.status === 401 || res.status === 403) return;
68
+ throw new Error(`/system/events HTTP ${res.status}`);
69
+ }
70
+ connected = true;
71
+ backoff = 1000; // healthy connection — reset
72
+ const reader = res.body.getReader();
73
+ const dec = new TextDecoder();
74
+ let buf = '';
75
+ while (!sig.aborted) {
76
+ const { done, value } = await reader.read();
77
+ if (done) break;
78
+ buf += dec.decode(value, { stream: true });
79
+ let i;
80
+ while ((i = buf.indexOf('\n\n')) !== -1) {
81
+ const frame = buf.slice(0, i); buf = buf.slice(i + 2);
82
+ for (const line of frame.split('\n')) {
83
+ if (!line.startsWith('data: ')) continue;
84
+ try { dispatch(JSON.parse(line.slice(6))); } catch {}
85
+ }
86
+ }
87
+ }
88
+ } catch (err) {
89
+ if (sig.aborted) return;
90
+ // Stream dropped — wait + reconnect.
91
+ }
92
+ connected = false;
93
+ if (sig.aborted) return;
94
+ await new Promise(r => setTimeout(r, backoff));
95
+ backoff = Math.min(backoff * 2, 30_000);
96
+ }
97
+ };
98
+ loop();
99
+ }
100
+
101
+ export function isSystemStreamConnected() {
102
+ return connected;
103
+ }
104
+
105
+ export function disconnectSystemStream() {
106
+ if (controller) { try { controller.abort(); } catch {} }
107
+ controller = null;
108
+ connected = false;
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/cli",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Email and SMS infrastructure for AI agents — the first platform to give agents real email addresses and phone numbers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@agenticmail/api": "^0.9.6",
32
+ "@agenticmail/api": "^0.9.7",
33
33
  "@agenticmail/core": "^0.9.2",
34
34
  "json5": "^2.2.3"
35
35
  },