@agenticmail/cli 0.9.8 → 0.9.10

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
@@ -176,10 +179,20 @@ function onFolderSelect(folder) {
176
179
  // Folder switches go through here too so the URL is the source of truth
177
180
  // for "what's on screen". If you bookmark or copy-paste a URL like
178
181
  // http://127.0.0.1:3829/#/folder/sent, opening it lands you on Sent.
182
+ // Track which view shape is currently on screen so the router knows
183
+ // whether navigating back to #/folder/<x> for the SAME folder should
184
+ // re-render the list. Without this, hitting Back from #/m/54 to
185
+ // #/folder/inbox would early-return because state.selectedFolder is
186
+ // still 'inbox' (it never changed when the message opened) — leaving
187
+ // the message-detail view stuck on screen even though the URL bar
188
+ // flipped back to the folder.
189
+ let currentView = 'folder'; // 'folder' | 'message' | 'draft'
190
+
179
191
  function route() {
180
192
  const hash = location.hash || '#/inbox';
181
193
  const msgMatch = hash.match(/^#\/m\/(\d+)$/);
182
194
  if (msgMatch) {
195
+ currentView = 'message';
183
196
  openMessage(Number(msgMatch[1]));
184
197
  return;
185
198
  }
@@ -188,23 +201,27 @@ function route() {
188
201
  // row click handler emits #/d/<uuid> for draft rows.
189
202
  const draftMatch = hash.match(/^#\/d\/([a-zA-Z0-9-]+)$/);
190
203
  if (draftMatch) {
204
+ currentView = 'draft';
191
205
  openDraft(draftMatch[1]);
192
206
  return;
193
207
  }
194
208
  const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
195
209
  const folder = folderMatch ? folderMatch[1] : 'inbox';
196
- // Only do work if the folder actually changed. Re-firing loadList
197
- // for the SAME folder on every hashchange (e.g. closing a message
198
- // detail back to the list) makes the UI feel sluggish — the list
199
- // is already rendered, and a second digest fetch just churns
200
- // through 50 messages on the IMAP server for no visible change.
201
- if (state.selectedFolder === folder) return;
210
+ // Skip the reload ONLY when we're already showing this folder's
211
+ // list view. Coming back from a message / draft folder must
212
+ // always re-render the list, even if state.selectedFolder hasn't
213
+ // changed since the message was opened.
214
+ if (currentView === 'folder' && state.selectedFolder === folder) return;
215
+ const folderChanged = state.selectedFolder !== folder;
202
216
  state.selectedFolder = folder;
203
- // Reset pagination on every folder switch — a fresh folder
204
- // starts at page 1. Preserved across silent SSE refreshes so
205
- // a new arrival doesn't yank the user back from page 3.
206
- state.pagination = { offset: 0, limit: 50, total: 0 };
207
- renderSidebar(onFolderSelect);
217
+ currentView = 'folder';
218
+ if (folderChanged) {
219
+ // Fresh folder page 1. Preserved across silent SSE refreshes so
220
+ // a new arrival doesn't yank the user back from page 3. We also
221
+ // re-render the sidebar so the active-folder highlight updates.
222
+ state.pagination = { offset: 0, limit: 50, total: 0 };
223
+ renderSidebar(onFolderSelect);
224
+ }
208
225
  if (state.selectedAgent) loadList(state.selectedAgent, folder);
209
226
  }
210
227
  window.addEventListener('hashchange', route);
@@ -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.10",
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.8",
33
33
  "@agenticmail/core": "^0.9.2",
34
34
  "json5": "^2.2.3"
35
35
  },