@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.
- package/dist/public/js/activity-badges.js +12 -30
- package/dist/public/js/app.js +34 -17
- package/dist/public/js/sse.js +27 -52
- package/dist/public/js/state.js +0 -1
- package/dist/public/js/system-stream.js +109 -0
- package/package.json +2 -2
|
@@ -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 {
|
|
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
|
|
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
|
|
95
|
-
*
|
|
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 (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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).
|
package/dist/public/js/app.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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);
|
package/dist/public/js/sse.js
CHANGED
|
@@ -1,47 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
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 view — silent 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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;
|
|
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
|
};
|
package/dist/public/js/state.js
CHANGED
|
@@ -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.
|
|
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.
|
|
32
|
+
"@agenticmail/api": "^0.9.8",
|
|
33
33
|
"@agenticmail/core": "^0.9.2",
|
|
34
34
|
"json5": "^2.2.3"
|
|
35
35
|
},
|