@agenticmail/cli 0.9.7 → 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';
@@ -95,20 +96,39 @@ async function bootstrap() {
95
96
  const initial = (lastId && state.agents.find(a => a.id === lastId))
96
97
  ?? state.agents.find(isBridgeAgent)
97
98
  ?? state.agents[0];
99
+ // Seed the URL hash BEFORE selectAgent so selectAgent's loadList
100
+ // call lands on the right folder. We use history.replaceState
101
+ // (NOT `location.hash = ...`) so this does NOT fire a hashchange
102
+ // event — that would trigger a second route() → loadList() in
103
+ // parallel with selectAgent's, doubling the work on every
104
+ // bootstrap. Read the hash first so a deep-link refresh
105
+ // (e.g. /#/folder/sent) still wins.
106
+ const folderMatch = location.hash.match(/^#\/folder\/([a-z]+)$/);
107
+ if (folderMatch) {
108
+ state.selectedFolder = folderMatch[1];
109
+ } else if (!location.hash) {
110
+ history.replaceState(null, '', `${location.pathname}${location.search}#/folder/inbox`);
111
+ }
98
112
  if (initial) await selectAgent(initial);
99
113
  renderProfile();
100
114
  populateComposeFrom();
101
- subscribeToAllAgents();
102
- // Real-time worker activity badges. Master-key-scoped SSE on
103
- // /system/events; the dispatcher's worker_started /
104
- // worker_heartbeat / worker_finished events drive the badge
105
- // rendering. Idempotent safe to call after bootstrap reruns.
106
- 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
107
123
  maybeRequestNotificationPermission();
108
- // Initial route: if the URL already has a hash (e.g. a refresh
109
- // on /#/folder/sent), respect it; otherwise default to inbox.
110
- if (!location.hash) location.hash = '#/folder/inbox';
111
- else route();
124
+ // If the URL points at a message (not a folder), open it now —
125
+ // the folder list selectAgent already loaded stays in the
126
+ // background. Folder hashes need no extra work; selectAgent's
127
+ // loadList already handled them above.
128
+ const hash = location.hash;
129
+ const msgMatch = hash.match(/^#\/m\/(\d+)$/);
130
+ const draftMatch = hash.match(/^#\/d\/([a-zA-Z0-9-]+)$/);
131
+ if (msgMatch || draftMatch) route();
112
132
  } catch (err) {
113
133
  toast(`Failed to load agents: ${err.message}`, true);
114
134
  }
@@ -176,14 +196,18 @@ function route() {
176
196
  }
177
197
  const folderMatch = hash.match(/^#\/folder\/([a-z]+)$/);
178
198
  const folder = folderMatch ? folderMatch[1] : 'inbox';
179
- if (state.selectedFolder !== folder) {
180
- state.selectedFolder = folder;
181
- // Reset pagination on every folder switcha fresh folder
182
- // starts at page 1. Preserved across silent SSE refreshes so
183
- // a new arrival doesn't yank the user back from page 3.
184
- state.pagination = { offset: 0, limit: 50, total: 0 };
185
- renderSidebar(onFolderSelect);
186
- }
199
+ // Only do work if the folder actually changed. Re-firing loadList
200
+ // for the SAME folder on every hashchange (e.g. closing a message
201
+ // detail back to the list) makes the UI feel sluggish the list
202
+ // is already rendered, and a second digest fetch just churns
203
+ // through 50 messages on the IMAP server for no visible change.
204
+ if (state.selectedFolder === folder) return;
205
+ state.selectedFolder = folder;
206
+ // Reset pagination on every folder switch — a fresh folder
207
+ // starts at page 1. Preserved across silent SSE refreshes so
208
+ // a new arrival doesn't yank the user back from page 3.
209
+ state.pagination = { offset: 0, limit: 50, total: 0 };
210
+ renderSidebar(onFolderSelect);
187
211
  if (state.selectedAgent) loadList(state.selectedAgent, folder);
188
212
  }
189
213
  window.addEventListener('hashchange', route);
@@ -359,7 +359,15 @@ export function renderList() {
359
359
  const prevBtn = document.getElementById('pager-prev');
360
360
  const nextBtn = document.getElementById('pager-next');
361
361
  if (prevBtn) prevBtn.disabled = offset <= 0;
362
- if (nextBtn) nextBtn.disabled = pageEnd >= total || state.messages.length < limit;
362
+ // Drive Next purely from the server-reported total. The previous
363
+ // `state.messages.length < limit` clause was meant as a "we hit the
364
+ // end" heuristic for the no-total fallback case, but it backfired
365
+ // on every folder that legitimately had fewer-than-`limit` items on
366
+ // a page (e.g. trailing partial page after deletions, or a folder
367
+ // whose IMAP STATUS returned a stale low count) — Next stayed
368
+ // permanently disabled. The new digest endpoint always returns an
369
+ // authoritative SEARCH-derived total, so a single check is enough.
370
+ if (nextBtn) nextBtn.disabled = pageEnd >= total;
363
371
 
364
372
  if (filtered.length === 0) {
365
373
  root.innerHTML = q
@@ -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.7",
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,12 +29,12 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@agenticmail/api": "^0.9.5",
32
+ "@agenticmail/api": "^0.9.7",
33
33
  "@agenticmail/core": "^0.9.2",
34
34
  "json5": "^2.2.3"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@agenticmail/claudecode": "^0.2.6"
37
+ "@agenticmail/claudecode": "^0.2.7"
38
38
  },
39
39
  "devDependencies": {
40
40
  "tsup": "^8.4.0",