@agenticmail/api 0.9.3 → 0.9.5

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/index.js CHANGED
@@ -5198,6 +5198,13 @@ function createDispatcherActivityRoutes() {
5198
5198
  existing.lastHeartbeatMs = Date.now();
5199
5199
  if (typeof body.lastTool === "string") existing.lastTool = body.lastTool;
5200
5200
  if (typeof body.turnCount === "number") existing.turnCount = body.turnCount;
5201
+ try {
5202
+ pushSystemEvent({
5203
+ type: "worker_heartbeat",
5204
+ worker: { ...existing }
5205
+ });
5206
+ } catch {
5207
+ }
5201
5208
  res.json({ ok: true });
5202
5209
  });
5203
5210
  router.get("/dispatcher/activity", requireMaster, (_req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/api",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "uuid": "^11.1.0"
37
37
  },
38
38
  "optionalDependencies": {
39
- "@agenticmail/claudecode": "^0.2.2"
39
+ "@agenticmail/claudecode": "^0.2.4"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/cors": "^2.8.17",
package/public/index.html CHANGED
@@ -39,6 +39,11 @@
39
39
  <button id="search-clear" class="search-clear-btn" title="Clear (Esc)" data-icon="close"></button>
40
40
  </div>
41
41
  <div class="topbar-right">
42
+ <!-- Real-time worker activity badges. Populated by SSE; one
43
+ badge per active dispatcher worker showing what each
44
+ agent is doing right now (reading, editing, running
45
+ shell, etc.). Empty when no workers are active. -->
46
+ <div id="activity-badges" class="activity-badges"></div>
42
47
  <button class="icon-btn" id="sound-toggle-btn" title="Notification sound"></button>
43
48
  <button class="icon-btn" id="refresh-btn" title="Refresh (r)" data-icon="refresh"></button>
44
49
  <button id="profile-btn" class="profile-trigger" title="Account">
@@ -0,0 +1,132 @@
1
+ // Real-time worker activity badges in the topbar.
2
+ //
3
+ // The dispatcher posts worker_started / worker_heartbeat /
4
+ // worker_finished events to /system/events. This module
5
+ // subscribes (master-key auth), maintains a map of active
6
+ // workers, and paints a small badge per active worker
7
+ // between the search bar and the notification bell.
8
+ //
9
+ // Each badge shows: agent avatar/initial · friendly status
10
+ // derived from the last tool the worker invoked. Updates
11
+ // arrive at the heartbeat cadence (30 s) so the badge text
12
+ // reflects what the agent is doing right now.
13
+
14
+ import { state, API_URL } from './state.js';
15
+
16
+ const BADGE_CONTAINER_ID = 'activity-badges';
17
+
18
+ /**
19
+ * Map of workerId → { agentName, kind, lastTool, turnCount,
20
+ * startedAtMs }. Maintained off the SSE stream; rendered into
21
+ * the badge container on every event.
22
+ */
23
+ const workers = new Map();
24
+ let sseController = null;
25
+
26
+ /**
27
+ * Map an SDK tool name (or the truncated head we capture in
28
+ * dispatcher logs) to a short verb. Falls back to "working"
29
+ * when we don't recognise the tool. The mapping is intentionally
30
+ * generic — exotic tools default to "working" rather than
31
+ * leaking the raw tool name to a user-facing badge.
32
+ */
33
+ function statusFor(lastTool) {
34
+ if (!lastTool) return 'starting';
35
+ const t = lastTool.toLowerCase();
36
+ if (t.startsWith('read')) return 'reading';
37
+ if (t.startsWith('write')) return 'writing code';
38
+ if (t.startsWith('edit')) return 'editing code';
39
+ if (t.startsWith('bash')) return 'running shell';
40
+ if (t.startsWith('grep')) return 'searching';
41
+ if (t.startsWith('glob')) return 'searching';
42
+ if (t.startsWith('webfetch')) return 'fetching web';
43
+ if (t.startsWith('websearch')) return 'searching web';
44
+ if (t.startsWith('notebookedit')) return 'editing notebook';
45
+ if (t.includes('send_email')) return 'sending mail';
46
+ if (t.includes('reply_email')) return 'replying';
47
+ if (t.includes('read_email')) return 'reading mail';
48
+ if (t.includes('list_inbox')) return 'checking inbox';
49
+ if (t.includes('search_emails')) return 'searching mail';
50
+ if (t.includes('call_agent')) return 'delegating';
51
+ if (t.includes('submit_result')) return 'finishing';
52
+ if (t.includes('save_thread_memory')) return 'saving memory';
53
+ if (t.startsWith('mcp__')) return 'using tool';
54
+ return 'working';
55
+ }
56
+
57
+ function render() {
58
+ const root = document.getElementById(BADGE_CONTAINER_ID);
59
+ if (!root) return;
60
+ const list = Array.from(workers.values()).sort((a, b) => (a.startedAtMs ?? 0) - (b.startedAtMs ?? 0));
61
+ if (list.length === 0) { root.innerHTML = ''; return; }
62
+ root.innerHTML = list.map(w => {
63
+ const initial = (w.agentName ?? '?').slice(0, 1).toUpperCase();
64
+ const status = statusFor(w.lastTool);
65
+ const tooltip = `${w.agentName} — ${status}${w.turnCount ? ` · ${w.turnCount} tool calls` : ''}${w.lastTool ? `\nlast tool: ${w.lastTool}` : ''}`;
66
+ return `
67
+ <div class="activity-badge" title="${escapeAttr(tooltip)}" data-worker-id="${escapeAttr(w.workerId ?? '')}">
68
+ <span class="badge-dot"></span>
69
+ <span class="badge-initial">${escapeHtml(initial)}</span>
70
+ <span class="badge-name">${escapeHtml(w.agentName ?? '?')}</span>
71
+ <span class="badge-status">${escapeHtml(status)}</span>
72
+ </div>
73
+ `;
74
+ }).join('');
75
+ }
76
+
77
+ function handleEvent(event) {
78
+ if (!event || typeof event !== 'object') return;
79
+ const w = event.worker;
80
+ if (!w?.workerId) return;
81
+ if (event.type === 'worker_started' || event.type === 'worker_heartbeat') {
82
+ // Merge so a heartbeat-after-started preserves the start
83
+ // metadata without re-fetching.
84
+ const existing = workers.get(w.workerId) ?? {};
85
+ workers.set(w.workerId, { ...existing, ...w });
86
+ render();
87
+ } else if (event.type === 'worker_finished') {
88
+ workers.delete(w.workerId);
89
+ render();
90
+ }
91
+ }
92
+
93
+ /**
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.
98
+ */
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 */ });
124
+ }
125
+
126
+ // Tiny HTML escapers (kept local to avoid an import cycle).
127
+ function escapeHtml(s) {
128
+ return String(s ?? '')
129
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
130
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
131
+ }
132
+ function escapeAttr(s) { return escapeHtml(s); }
package/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 { subscribeToActivity } from './activity-badges.js';
17
18
  import { icon } from './icons.js';
18
19
  import { isSoundEnabled, setSoundEnabled, playNotificationSound } from './sound.js';
19
20
 
@@ -98,6 +99,11 @@ async function bootstrap() {
98
99
  renderProfile();
99
100
  populateComposeFrom();
100
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();
101
107
  maybeRequestNotificationPermission();
102
108
  // Initial route: if the URL already has a hash (e.g. a refresh
103
109
  // on /#/folder/sent), respect it; otherwise default to inbox.
@@ -122,6 +128,8 @@ async function selectAgent(agent) {
122
128
  // account that uses different folder names (e.g. Gmail relay
123
129
  // vs vanilla Stalwart) keeps the previous cache.
124
130
  state.folderNames = {};
131
+ // Reset pagination — each inbox starts at page 1.
132
+ state.pagination = { offset: 0, limit: 50, total: 0 };
125
133
  // Discover folders BEFORE the first sidebar render so the
126
134
  // `requiresDiscovery` hide-rule (All Mail on non-Gmail servers)
127
135
  // has the cache to consult. Falls back to defaults on failure.
@@ -170,6 +178,10 @@ function route() {
170
178
  const folder = folderMatch ? folderMatch[1] : 'inbox';
171
179
  if (state.selectedFolder !== folder) {
172
180
  state.selectedFolder = folder;
181
+ // Reset pagination on every folder switch — a 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 };
173
185
  renderSidebar(onFolderSelect);
174
186
  }
175
187
  if (state.selectedAgent) loadList(state.selectedAgent, folder);
@@ -107,9 +107,19 @@ export async function loadList(agent, folder) {
107
107
  </div>
108
108
  <div class="list-toolbar-spacer"></div>
109
109
  <span class="count-text" id="list-count"></span>
110
+ <button class="icon-btn pager-btn" id="pager-prev" title="Newer" data-icon="back"></button>
111
+ <button class="icon-btn pager-btn" id="pager-next" title="Older"></button>
110
112
  </div>
111
113
  <div class="list-rows" id="list-rows"><div class="empty">Loading…</div></div>
112
114
  `;
115
+ // Pager buttons. "Newer" goes back to a lower offset (closer to
116
+ // the head of the inbox); "Older" advances to higher offsets.
117
+ // Each handler clamps to valid bounds and refetches via loadList
118
+ // with the new offset preserved in state.pagination.
119
+ document.getElementById('pager-next').innerHTML = icon('back', { size: 18 });
120
+ document.getElementById('pager-next').style.transform = 'rotate(180deg)';
121
+ document.getElementById('pager-prev')?.addEventListener('click', () => goToPage(agent, folder, -1));
122
+ document.getElementById('pager-next')?.addEventListener('click', () => goToPage(agent, folder, +1));
113
123
  document.getElementById('list-refresh-btn')?.addEventListener('click', () => loadList(agent, folder));
114
124
  document.getElementById('list-select-all-input')?.addEventListener('change', (e) => {
115
125
  const checked = e.target.checked;
@@ -152,9 +162,18 @@ export async function loadList(agent, folder) {
152
162
  // Previously we used `/mail/inbox` (no preview) and `/mail/
153
163
  // folders/:folder` (no preview, wrong folder names), which left
154
164
  // every row stuck on subject + sender alone.
155
- const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=50&offset=0&previewLength=240`;
165
+ const { offset, limit } = state.pagination;
166
+ const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=${limit}&offset=${offset}&previewLength=240`;
156
167
  const data = await apiGet(url, { agentKey: agent.apiKey });
157
168
  state.messages = data.messages ?? [];
169
+ // The digest endpoint returns `total` — use it for the pager.
170
+ // Fall back to (offset + page-size) when missing so the
171
+ // pager still functions in a degraded "we don't know the
172
+ // upper bound" mode (Next stays enabled until a fetch
173
+ // returns a short page).
174
+ state.pagination.total = typeof data?.total === 'number'
175
+ ? data.total
176
+ : (state.messages.length === limit ? offset + limit + 1 : offset + state.messages.length);
158
177
  renderList();
159
178
  } catch (err) {
160
179
  const msg = String(err.message ?? err);
@@ -188,7 +207,11 @@ function folderTitle(folder) {
188
207
  async function loadDraftsList(agent) {
189
208
  try {
190
209
  const data = await apiGet('/drafts', { agentKey: agent.apiKey });
191
- const rows = Array.isArray(data?.drafts) ? data.drafts : [];
210
+ const all = Array.isArray(data?.drafts) ? data.drafts : [];
211
+ const { offset, limit } = state.pagination;
212
+ // Drafts come from SQL as a single list; paginate client-side.
213
+ const rows = all.slice(offset, offset + limit);
214
+ state.pagination.total = all.length;
192
215
  state.messages = rows.map(r => ({
193
216
  // We store the draft id under `uid` so renderList +
194
217
  // click handlers can use the same field. Drafts also
@@ -214,6 +237,25 @@ async function loadDraftsList(agent) {
214
237
  }
215
238
  }
216
239
 
240
+ /**
241
+ * Advance the pager by one page in the given direction (-1 newer,
242
+ * +1 older). Clamps to valid bounds, then triggers loadList for the
243
+ * fresh offset. Bound to the prev/next toolbar buttons.
244
+ */
245
+ function goToPage(agent, folder, direction) {
246
+ const { offset, limit, total } = state.pagination;
247
+ const next = offset + direction * limit;
248
+ if (next < 0) return; // already at newest
249
+ if (next >= total && direction > 0) return; // already at oldest
250
+ state.pagination.offset = Math.max(0, next);
251
+ // Scroll to top so the user lands on the first row of the new
252
+ // page rather than at the bottom of where the previous page
253
+ // ended.
254
+ const rows = document.getElementById('list-rows');
255
+ if (rows) rows.scrollTop = 0;
256
+ loadList(agent, folder);
257
+ }
258
+
217
259
  /**
218
260
  * Refresh the currently-rendered list without rebuilding the
219
261
  * toolbar. Used by the SSE new-mail handler so a new email
@@ -229,9 +271,12 @@ async function loadDraftsList(agent) {
229
271
  export async function silentRefresh(agent, folder) {
230
272
  if (!agent) return;
231
273
  try {
274
+ const { offset, limit } = state.pagination;
232
275
  if (folder === 'drafts') {
233
276
  const data = await apiGet('/drafts', { agentKey: agent.apiKey });
234
- const rows = Array.isArray(data?.drafts) ? data.drafts : [];
277
+ const all = Array.isArray(data?.drafts) ? data.drafts : [];
278
+ state.pagination.total = all.length;
279
+ const rows = all.slice(offset, offset + limit);
235
280
  state.messages = rows.map(r => ({
236
281
  uid: r.id,
237
282
  __draftId: r.id,
@@ -249,9 +294,12 @@ export async function silentRefresh(agent, folder) {
249
294
  const isStarred = folder === 'starred';
250
295
  const imap = isStarred ? 'INBOX' : (state.folderNames?.[folder]);
251
296
  if (!imap) return;
252
- const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=50&offset=0&previewLength=240`;
297
+ const url = `/mail/digest?folder=${encodeURIComponent(imap)}&limit=${limit}&offset=${offset}&previewLength=240`;
253
298
  const data = await apiGet(url, { agentKey: agent.apiKey });
254
299
  state.messages = data.messages ?? [];
300
+ state.pagination.total = typeof data?.total === 'number'
301
+ ? data.total
302
+ : (state.messages.length === limit ? offset + limit + 1 : offset + state.messages.length);
255
303
  renderList();
256
304
  } catch { /* silent — next user action will repair */ }
257
305
  }
@@ -295,8 +343,23 @@ export function renderList() {
295
343
  } else if (hintEl) {
296
344
  hintEl.classList.remove('show');
297
345
  }
346
+ // Gmail-style "X-Y of Z" pager label + button enable/disable.
347
+ // We show the current PAGE range against the server-reported
348
+ // total, not just the filtered window — pagination is page-
349
+ // level; search is row-level filtering within the current page.
298
350
  const countEl = document.getElementById('list-count');
299
- if (countEl) countEl.textContent = `${filtered.length} of ${state.messages.length}`;
351
+ const { offset, limit, total } = state.pagination;
352
+ const pageStart = state.messages.length > 0 ? offset + 1 : 0;
353
+ const pageEnd = offset + state.messages.length;
354
+ if (countEl) {
355
+ countEl.textContent = total > 0
356
+ ? `${pageStart}–${pageEnd} of ${total}`
357
+ : (state.messages.length > 0 ? `${pageStart}–${pageEnd}` : '0');
358
+ }
359
+ const prevBtn = document.getElementById('pager-prev');
360
+ const nextBtn = document.getElementById('pager-next');
361
+ if (prevBtn) prevBtn.disabled = offset <= 0;
362
+ if (nextBtn) nextBtn.disabled = pageEnd >= total || state.messages.length < limit;
300
363
 
301
364
  if (filtered.length === 0) {
302
365
  root.innerHTML = q
@@ -24,6 +24,16 @@ export const state = {
24
24
  * showed.
25
25
  */
26
26
  folderNames: {}, // { [sidebarId]: imapFolderName }
27
+ /**
28
+ * Pagination state for the currently-rendered list. `offset` is
29
+ * the index of the FIRST message in the current view; `limit` is
30
+ * the page size; `total` is the server-reported total count for
31
+ * the folder (or the local row count for drafts). Reset to
32
+ * offset=0 on folder switch + agent switch; preserved across
33
+ * silent SSE refreshes so a new arrival doesn't yank the user
34
+ * back to page 1.
35
+ */
36
+ pagination: { offset: 0, limit: 50, total: 0 },
27
37
  };
28
38
 
29
39
  export const API_URL = window.location.origin;
package/public/styles.css CHANGED
@@ -164,6 +164,61 @@ a { color: var(--accent-strong); }
164
164
  #sound-toggle-btn.sound-on { color: var(--pink); }
165
165
  #sound-toggle-btn.sound-off { color: var(--muted); }
166
166
 
167
+ /* ─── Activity badges (live worker status) ───────────────────────
168
+ One pill per active dispatcher worker. Populated by SSE on the
169
+ `worker_started` / `worker_heartbeat` / `worker_finished`
170
+ events. Each badge shows the agent's initial + name + a short
171
+ verb derived from the worker's `lastTool`. The green dot
172
+ gently pulses so the row reads as "alive" rather than static. */
173
+ .activity-badges {
174
+ display: flex; align-items: center; gap: 6px;
175
+ max-width: 480px;
176
+ overflow-x: auto;
177
+ scrollbar-width: none;
178
+ margin-right: 4px;
179
+ }
180
+ .activity-badges::-webkit-scrollbar { display: none; }
181
+ .activity-badge {
182
+ display: inline-flex; align-items: center; gap: 6px;
183
+ padding: 4px 10px 4px 6px;
184
+ background: var(--bg);
185
+ border: 1px solid var(--line);
186
+ border-radius: 999px;
187
+ font-size: 12px;
188
+ color: var(--ink-soft);
189
+ white-space: nowrap;
190
+ cursor: default;
191
+ }
192
+ .activity-badge:hover { background: var(--bg-hover); }
193
+ .activity-badge .badge-dot {
194
+ width: 6px; height: 6px; border-radius: 50%;
195
+ background: #34a853; /* live-green */
196
+ flex-shrink: 0;
197
+ box-shadow: 0 0 0 0 rgba(52, 168, 83, 0.5);
198
+ animation: activity-pulse 2s infinite;
199
+ }
200
+ @keyframes activity-pulse {
201
+ 0% { box-shadow: 0 0 0 0 rgba(52, 168, 83, 0.5); }
202
+ 70% { box-shadow: 0 0 0 6px rgba(52, 168, 83, 0); }
203
+ 100% { box-shadow: 0 0 0 0 rgba(52, 168, 83, 0); }
204
+ }
205
+ .activity-badge .badge-initial {
206
+ display: inline-flex; align-items: center; justify-content: center;
207
+ width: 18px; height: 18px; border-radius: 50%;
208
+ background: var(--pink); color: white;
209
+ font-size: 10px; font-weight: 700;
210
+ flex-shrink: 0;
211
+ }
212
+ .activity-badge .badge-name { font-weight: 500; color: var(--ink); }
213
+ .activity-badge .badge-status { color: var(--muted); }
214
+ .activity-badge .badge-status::before { content: '· '; opacity: .6; }
215
+
216
+ /* Mobile — hide badges on narrow viewports; they fold to the
217
+ topbar's account avatar dropdown anyway. */
218
+ @media (max-width: 800px) {
219
+ .activity-badges { display: none; }
220
+ }
221
+
167
222
  /* ─── Profile (top right) ─────────────────────────────────────────── */
168
223
  .profile-trigger {
169
224
  width: 40px; height: 40px; border-radius: 50%;
@@ -421,6 +476,19 @@ a { color: var(--accent-strong); }
421
476
  .list-toolbar-spacer { flex: 1; }
422
477
  .list-toolbar .count-text {
423
478
  font-size: 12px; color: var(--muted);
479
+ margin-right: 8px;
480
+ }
481
+ /* Gmail-style pager. Buttons inherit .icon-btn styling; disabled
482
+ state mutes the colour so the user can tell at a glance whether
483
+ prev/next is available without trying to click. */
484
+ .pager-btn {
485
+ width: 32px; height: 32px;
486
+ color: var(--ink-soft);
487
+ }
488
+ .pager-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--ink); }
489
+ .pager-btn:disabled {
490
+ color: var(--line);
491
+ cursor: default;
424
492
  }
425
493
 
426
494
  /* Bulk-action toolbar — appears between select-all and refresh