Settings
Manage invitations, devices, and access.
Advanced settings · Devices and access
Access keys
Keys are shown once. Revoking a key signs out the corresponding device.
| LABEL | IDENTITY | STATUS |
|---|
Send messages
}seq is a server-assigned monotonic message sequence. Gaps are permitted. IDs are opaque. Timestamps are UTC ISO 8601 strings, used for display rather than pagination. A request provides no sender override, timestamp, or client cursor.
Owner receiving diagnostics
+GET /agents/receiving requires an authenticated Owner and returns
+{items, observed_at, check_resolution_seconds: 30} with Cache-Control: no-store.
+Each Agent includes id, name, active, last_checked_at, last_sent_at,
+last_ack_at, pending_count, and nullable oldest_pending message metadata
+(id, seq, thread_id, created_at; no body). These are independent historical
+observations, not a per-message execution trace. Reading this endpoint neither
+fetches inbox messages for an Agent nor acknowledges them.
notification contains state, connections and last_heartbeat_at.
+Cloudflare inspects authenticated, open sockets through the private Durable Object
+binding. A heartbeat younger than 120 seconds is connected; a new socket awaiting
+its first heartbeat is connecting. Older heartbeat evidence is stale; no valid
+open sockets is disconnected. Unavailable or timed-out observations are unknown,
+not disconnected. Disabled identities are disabled. Without a notification
+binding (Node/SQLite), state is unsupported. Socket count and heartbeat time are
+null when unavailable; credentials, token IDs and socket attachments are never
+returned. Object queries use bounded concurrency and a four-second page deadline.
receiving_mode and fallback_interval_seconds are currently null: native host
+schedules/model execution cannot be inferred from connection state or ACK timing.
+Apply migration 0015_agent_receiving_activity.sql before deploying. Successful
+Agent /probe and /inbox requests record server timestamps, coalescing updates
+within 30 seconds. This is not a read receipt for any particular message. Failed
+observability writes do not fail mailbox access. Earlier check history remains
+unknown. Existing CLI clients work without an update.
Receive and acknowledge
GET /probe is an Agent-only authenticated idle check. It returns exactly
{agent:{id,name},pending:boolean,membership_cursor:string}. pending is true
diff --git a/public/receiving-locales.js b/public/receiving-locales.js
new file mode 100644
index 0000000..288f190
--- /dev/null
+++ b/public/receiving-locales.js
@@ -0,0 +1,52 @@
+export const receivingTranslations = {
+ 'Agent 需要运行监听器或受支持的定时任务来检查消息。连接正常不保证消息已处理。': 'Agents need a running listener or a supported scheduler to check messages. A connection does not guarantee processing.',
+ '接收状态': 'Receiving status',
+ 'Agent 接收状态': 'Agent receiving status',
+ '查看通知连接、收件检查与消息处理情况。': 'Notification connections, inbox checks and message processing.',
+ '刷新': 'Refresh',
+ '观测时间 {0}': 'Observed at {0}',
+ '正在加载接收状态…': 'Loading receiving status…',
+ '状态刷新失败。保留的记录已过期,当前连接状态未知。': 'Status could not be refreshed. Previous records are stale; current connections are unknown.',
+ '搜索 Agent': 'Search Agents',
+ '已启用 Agent': 'Enabled Agents',
+ '实时连接': 'Realtime connections',
+ '待处理消息': 'Pending messages',
+ '状态未知': 'Unknown status',
+ '全部 Agent': 'All Agents',
+ '需关注': 'Needs attention',
+ '实时通知': 'Realtime notifications',
+ '最近收件检查': 'Last inbox check',
+ '最近 ACK': 'Last acknowledgment',
+ '未连接不代表离线。ACK 表示已处理,不保证任务结果正确。收件检查时间最多延迟 30 秒。': 'Not connected does not mean offline. ACK confirms handling, not task correctness. Inbox check times may lag by up to 30 seconds.',
+ '已连接': 'Connected',
+ '等待心跳': 'Awaiting heartbeat',
+ '心跳已过期': 'Heartbeat stale',
+ '未连接': 'Not connected',
+ '未知': 'Unknown',
+ '不支持': 'Not supported',
+ '已启用': 'Enabled',
+ '心跳 {0}': 'Heartbeat {0}',
+ '最早:{0}': 'Oldest: {0}',
+ '未记录': 'Not recorded',
+ '{0} 秒前': '{0} seconds ago',
+ '{0} 分钟前': '{0} minutes ago',
+ '{0} 小时前': '{0} hours ago',
+ '{0} 天前': '{0} days ago',
+ '没有符合筛选条件的 Agent。': 'No Agents match this filter.',
+ '还没有 Agent,请从聊天页接入。': 'No Agents connected yet. Connect an Agent from Chats.',
+ 'Agent 详情': 'Agent details',
+ '等待处理': 'Awaiting processing',
+ '没有待处理消息': 'No pending messages',
+ '{0} 条待处理 · 最早 {1}': '{0} pending · oldest {1}',
+ '连接和处理是不同的状态。以下是最近观测记录,不保证模型当前可用。': 'Connection and processing are separate. These are the latest observations, not a model health guarantee.',
+ '最早待处理消息已保存': 'Oldest pending message saved',
+ '最近发送消息': 'Last sent message',
+ '接收配置': 'Receiving configuration',
+ '接收模式': 'Receiving mode',
+ '兜底间隔': 'Fallback interval',
+ '未上报': 'Not reported',
+ '可在 Agent 所在平台检查接收任务。': 'Check the receiving task on the Agent’s host platform.',
+ '打开待处理会话': 'Open pending conversation',
+ '打开会话': 'Open conversation',
+ '暂无与该 Agent 的私聊。': 'No direct conversation with this Agent yet.'
+};
diff --git a/public/receiving-status.css b/public/receiving-status.css
new file mode 100644
index 0000000..ac1ff9f
--- /dev/null
+++ b/public/receiving-status.css
@@ -0,0 +1,51 @@
+.receiving-page{max-width:none;background:var(--sidebar);padding:32px;overflow:auto}
+.receiving-header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}
+.receiving-header h1{font-size:28px;margin-bottom:8px}
+.receiving-actions{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+.receiving-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:24px}
+.receiving-stat{padding:20px 24px;border:1px solid var(--line);border-radius:10px;background:var(--paper);display:flex;flex-direction:column;gap:12px}
+.receiving-stat strong{font-size:28px;font-variant-numeric:tabular-nums}
+.receiving-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:20px;align-items:start}
+.receiving-layout.has-detail{grid-template-columns:minmax(0,1fr) 310px}
+.receiving-list,.receiving-detail{background:var(--paper);border:1px solid #44474f;border-radius:10px;min-width:0}
+.receiving-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px;flex-wrap:wrap;border-bottom:1px solid var(--line)}
+.receiving-toolbar input{margin:0;max-width:280px;min-width:180px;flex:1;background:var(--base)}
+.receiving-filters{display:flex;gap:8px;flex-wrap:wrap}
+.receiving-filters button{padding:8px 12px;font-size:12px;border:1px solid #51545e}
+.receiving-filters button[aria-pressed=true]{border-color:var(--accent);color:#e6e8ff;background:#5865cf30}
+.receiving-scroll{overflow:auto;border-radius:0 0 10px 10px}
+.receiving-list table{width:100%;border-collapse:collapse;font-size:13px;min-width:650px}
+.receiving-list th{padding:16px;text-align:left;color:var(--muted);font-size:11px;font-weight:600;white-space:nowrap;border-bottom:1px solid var(--line)}
+.receiving-list td{padding:18px 16px;border-bottom:1px solid var(--line);vertical-align:middle;font-variant-numeric:tabular-nums}
+.receiving-list td small{display:block;margin-top:7px;color:var(--muted);font-size:11px;line-height:1.5}
+.receiving-list tr.selected{background:#5865cf20;box-shadow:inset 3px 0 var(--accent)}
+.receiving-agent{display:flex;align-items:center;gap:12px;text-align:left;background:transparent;padding:0;min-width:130px}
+.receiving-agent strong{display:block;max-width:200px;overflow-wrap:anywhere}
+.receiving-agent .avatar,.receiving-detail .avatar{width:38px;height:38px;flex-shrink:0}
+.receiving-transport{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;color:var(--muted)}
+.receiving-transport:before{content:"";display:block;width:8px;height:8px;flex-shrink:0;border-radius:50%;border:1px solid currentColor}
+.receiving-transport.connected{color:#8eddb2}
+.receiving-transport.connected:before{background:currentColor}
+.receiving-transport.stale,.receiving-amber{color:#f2bf68}
+.receiving-pending{display:inline-block;border:1px solid #bd8f3e;color:#f2bf68;background:#b68a2720;border-radius:12px;padding:2px 9px;font-size:12px}
+.receiving-disabled{color:var(--muted)}
+.receiving-disabled .avatar{filter:grayscale(1);opacity:.65}
+.receiving-hint{padding:18px}
+.receiving-empty{padding:42px 24px;text-align:center;color:var(--muted)}
+.receiving-detail{padding:22px;display:flex;flex-direction:column;gap:18px}
+.receiving-detail>header{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--line)}
+.receiving-detail h2{font-size:18px;overflow-wrap:anywhere;min-width:0}
+.receiving-detail header button{margin-left:auto;flex-shrink:0}
+.receiving-detail>.receiving-pending{align-self:flex-start}
+.receiving-detail h3{font-size:14px;line-height:1.6}
+.receiving-wait{color:#f2bf68}
+.receiving-timeline{margin:0;display:flex;flex-direction:column;gap:22px;border-left:1px solid #626670;padding-left:18px;margin-left:5px}
+.receiving-timeline>div{position:relative;display:flex;justify-content:space-between;gap:14px;font-size:12px;line-height:1.6}
+.receiving-timeline>div:before{content:"";position:absolute;width:7px;height:7px;border:1px solid #a4a8b2;background:var(--paper);border-radius:50%;left:-23px;top:5px}
+.receiving-timeline dd{margin:0;color:var(--muted);text-align:right;flex-shrink:0}
+.receiving-config{border:1px solid #4b4e58;border-radius:7px;padding:14px}
+.receiving-config p{display:flex;justify-content:space-between;gap:16px;font-size:12px;color:var(--muted);margin-top:14px}
+.receiving-error{margin-bottom:18px;padding:12px 16px;border:1px solid #806631;background:#b68a2715;border-radius:7px;color:#eac88c;font-size:13px;line-height:1.6}
+@media(min-width:1700px){.receiving-layout.has-detail{grid-template-columns:minmax(0,1fr) 360px}}
+@media(max-width:1150px){.receiving-layout.has-detail{grid-template-columns:minmax(0,1fr)}.receiving-detail{max-width:none}.receiving-header{align-items:flex-start;flex-direction:column}}
+@media(max-width:640px){.receiving-page{padding:18px 12px}.receiving-header h1{font-size:24px}.receiving-stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.receiving-stat{padding:14px}.receiving-toolbar{padding:12px}.receiving-toolbar input{max-width:none}.receiving-filters button{padding:7px}.receiving-layout.has-detail .receiving-detail{grid-row:1}}
diff --git a/public/receiving-status.js b/public/receiving-status.js
new file mode 100644
index 0000000..c17a77c
--- /dev/null
+++ b/public/receiving-status.js
@@ -0,0 +1,120 @@
+import { createChatSync } from './chat-sync.js';
+
+export const notificationLabel = state => ({ connected: 'Connected', connecting: 'Awaiting heartbeat', stale: 'Heartbeat stale', disconnected: 'Not connected', unknown: 'Unknown', unsupported: 'Not supported', disabled: 'Disabled' })[state] ?? 'Unknown';
+export function receivingSummary(items) {
+ const active = items.filter(p => p.active);
+ return { enabled: active.length, connected: active.filter(p => p.notification.state === 'connected').length,
+ pending: active.reduce((n, p) => n + p.pending_count, 0), unknown: active.filter(p => p.notification.state === 'unknown').length };
+}
+
+export function createReceivingPage({ root, api, t, locale, avatar, openConversation, onUnauthorized }) {
+ const el = (tag, className, text) => { const n = document.createElement(tag); if (className) n.className = className; if (text !== undefined) n.textContent = text; return n; };
+ const button = (text, className, action) => { const n = el('button', className, text); n.type = 'button'; n.addEventListener('click', action); return n; };
+ let uncertain = false;
+ let data = null, selected = null, filter = 'all', syncState = { status: 'idle' };
+ const header = el('header', 'receiving-header'), heading = el('div'), title = el('h1'), subtitle = el('p', 'fine');
+ const actions = el('div', 'receiving-actions'), freshness = el('span', 'fine'); freshness.setAttribute('role', 'status');
+ const refresh = button('', 'secondary', () => void sync.refresh().catch(() => {}));
+ heading.append(title, subtitle); actions.append(freshness, refresh); header.append(heading, actions);
+ const error = el('p', 'receiving-error'); error.setAttribute('role', 'status'); error.hidden = true;
+ const stats = el('div', 'receiving-stats'), layout = el('div', 'receiving-layout'), list = el('section', 'receiving-list');
+ const toolbar = el('div', 'receiving-toolbar'), search = el('input'), filters = el('div', 'receiving-filters');
+ search.type = 'search'; search.addEventListener('input', renderRows); toolbar.append(search, filters);
+ const scroll = el('div', 'receiving-scroll'), table = el('table'), thead = el('thead'), tbody = el('tbody');
+ table.append(thead, tbody); scroll.append(table);
+ const empty = el('p', 'receiving-empty'), hint = el('p', 'receiving-hint fine'); empty.setAttribute('role', 'status');
+ list.append(toolbar, scroll, empty, hint);
+ const detail = el('aside', 'receiving-detail'); detail.hidden = true; detail.setAttribute('aria-label', t('Agent details'));
+ layout.append(list, detail); root.append(header, error, stats, layout);
+ const stale = () => uncertain;
+ const items = () => (data?.items ?? []).map(p => stale() && p.active ? { ...p, notification: { state: 'unknown', last_heartbeat_at: null } } : p);
+ function relative(value) {
+ if (!value) return t('Not recorded');
+ // Anchor to the server clock, not an owner's potentially skewed device clock.
+ const seconds = Math.max(0, Math.floor((Date.parse(data.observed_at) - Date.parse(value)) / 1000));
+ if (seconds < 60) return t('{0} seconds ago', seconds);
+ if (seconds < 3600) return t('{0} minutes ago', Math.floor(seconds / 60));
+ if (seconds < 86400) return t('{0} hours ago', Math.floor(seconds / 3600));
+ return t('{0} days ago', Math.floor(seconds / 86400));
+ }
+ function stamp(value) {
+ const node = el('span', '', relative(value));
+ if (value) node.title = new Date(value).toLocaleString(locale());
+ return node;
+ }
+ function renderRows() {
+ const focusId = document.activeElement?.dataset.receivingAgent;
+ const visible = items().filter(p => (!search.value || p.name.toLowerCase().includes(search.value.toLowerCase()))
+ && (filter === 'all' || filter === 'disabled' && !p.active || filter === 'attention' && p.active && p.pending_count > 0));
+ tbody.replaceChildren();
+ for (const p of visible) {
+ const row = el('tr', `${p.id === selected ? 'selected ' : ''}${!p.active ? 'receiving-disabled' : ''}`);
+ const name = el('td'), nameButton = button('', 'receiving-agent', () => { selected = p.id; renderRows(); renderDetail(); });
+ nameButton.dataset.receivingAgent = p.id; nameButton.setAttribute('aria-expanded', String(selected === p.id));
+ const label = el('span'); const strong = el('strong', '', p.name); strong.dataset.userContent = '';
+ label.append(strong, el('small', '', t(p.active ? 'Enabled' : 'Disabled'))); nameButton.append(avatar(p), label); name.append(nameButton);
+ const transport = el('td'), badge = el('span', `receiving-transport ${p.notification.state}`, t(notificationLabel(p.notification.state)));
+ transport.append(badge);
+ if (p.notification.last_heartbeat_at) transport.append(el('small', '', t('Heartbeat {0}', relative(p.notification.last_heartbeat_at))));
+ const checked = el('td'); checked.append(stamp(p.last_checked_at));
+ const pending = el('td'); pending.append(el('span', p.active && p.pending_count ? 'receiving-pending' : '', String(p.pending_count)));
+ if (p.oldest_pending) pending.append(el('small', '', t('Oldest: {0}', relative(p.oldest_pending.created_at))));
+ const ack = el('td'); ack.append(stamp(p.last_ack_at)); row.append(name, transport, checked, pending, ack); tbody.append(row);
+ }
+ empty.hidden = visible.length > 0;
+ empty.textContent = !data ? t('Loading receiving status…') : data.items.length ? t('No Agents match this filter.') : t('No Agents connected yet. Connect an Agent from Chats.');
+ if (focusId) [...tbody.querySelectorAll('button')].find(n => n.dataset.receivingAgent === focusId)?.focus({ preventScroll: true });
+ }
+ function renderDetail() {
+ const focusedAction = detail.contains(document.activeElement) ? document.activeElement?.dataset.receivingAction : null;
+ const p = items().find(p => p.id === selected); detail.hidden = !p; layout.classList.toggle('has-detail', !!p); detail.replaceChildren();
+ if (!p) return;
+ const top = el('header'), name = el('h2', '', p.name); name.dataset.userContent = '';
+ const close = button('×', 'icon-button', () => { const id = selected; selected = null; renderRows(); renderDetail(); [...tbody.querySelectorAll('button')].find(n => n.dataset.receivingAgent === id)?.focus(); });
+ close.dataset.receivingAction = 'close'; close.setAttribute('aria-label', t('Close details')); top.append(avatar(p), name, close); detail.append(top);
+ detail.append(el('span', p.active && p.pending_count ? 'receiving-pending' : 'fine', t(!p.active ? 'Disabled' : p.pending_count ? 'Awaiting processing' : 'No pending messages')));
+ if (p.oldest_pending) detail.append(el('h3', 'receiving-wait', t('{0} pending · oldest {1}', p.pending_count, relative(p.oldest_pending.created_at))));
+ detail.append(el('p', 'fine', t('Connection and processing are separate. These are the latest observations, not a model health guarantee.')));
+ const timeline = el('dl', 'receiving-timeline');
+ for (const [label, value] of [[p.oldest_pending ? 'Oldest pending message saved' : 'Pending messages', p.oldest_pending?.created_at], ['Last inbox check', p.last_checked_at], ['Last sent message', p.last_sent_at], ['Last acknowledgment', p.last_ack_at]]) {
+ const row = el('div'); row.append(el('dt', '', t(label))); const dd = el('dd');
+ if (label === 'Pending messages') dd.textContent = '0'; else dd.append(stamp(value));
+ row.append(dd); timeline.append(row);
+ }
+ detail.append(timeline);
+ const config = el('section', 'receiving-config'); config.append(el('h3', '', t('Receiving configuration')));
+ for (const label of ['Receiving mode', 'Fallback interval']) { const row = el('p'); row.append(el('span', '', t(label)), el('span', '', t('Not reported'))); config.append(row); }
+ detail.append(config, el('p', 'fine', t('Check the receiving task on the Agent’s host platform.')));
+ const open = button(t(p.oldest_pending ? 'Open pending conversation' : 'Open conversation'), 'primary', () => void openConversation(p).catch(e => { error.textContent = e.message; error.hidden = false; }));
+ open.dataset.receivingAction = 'open'; detail.append(open);
+ if (focusedAction) [...detail.querySelectorAll('button')].find(n => n.dataset.receivingAction === focusedAction)?.focus({ preventScroll: true });
+ }
+ function render() {
+ title.textContent = t('Agent receiving status'); subtitle.textContent = t('Notification connections, inbox checks and message processing.');
+ refresh.textContent = t('Refresh'); refresh.disabled = syncState.status === 'syncing';
+ freshness.textContent = data ? t('Observed at {0}', new Date(data.observed_at).toLocaleTimeString(locale())) : t('Loading receiving status…');
+ error.hidden = !stale(); error.textContent = t('Status could not be refreshed. Previous records are stale; current connections are unknown.');
+ search.placeholder = t('Search Agents'); search.setAttribute('aria-label', t('Search Agents'));
+ const summary = receivingSummary(items()); stats.replaceChildren();
+ for (const [label, key] of [['Enabled Agents', 'enabled'], ['Realtime connections', 'connected'], ['Pending messages', 'pending'], ['Unknown status', 'unknown']]) {
+ const card = el('div', 'receiving-stat'); card.append(el('span', 'fine', t(label)), el('strong', key === 'pending' && summary[key] ? 'receiving-amber' : '', data ? String(summary[key]) : '—')); stats.append(card);
+ }
+ const focusedFilter = document.activeElement?.dataset.receivingFilter;
+ filters.replaceChildren();
+ for (const [key, label, count] of [['all', 'All Agents', data?.items.length ?? 0], ['attention', 'Needs attention', items().filter(p => p.active && p.pending_count > 0).length], ['disabled', 'Disabled', items().filter(p => !p.active).length]]) {
+ const b = button(`${t(label)} ${count}`, 'secondary', () => { filter = key; render(); }); b.dataset.receivingFilter = key;
+ b.setAttribute('aria-pressed', String(filter === key)); filters.append(b);
+ }
+ if (focusedFilter) [...filters.children].find(b => b.dataset.receivingFilter === focusedFilter)?.focus({ preventScroll: true });
+ const headers = el('tr'); for (const text of ['Agent', 'Realtime notifications', 'Last inbox check', 'Pending messages', 'Last acknowledgment']) { const th = el('th', '', t(text)); th.scope = 'col'; headers.append(th); } thead.replaceChildren(headers);
+ hint.textContent = t('Not connected does not mean offline. ACK confirms handling, not task correctness. Inbox check times may lag by up to 30 seconds.');
+ detail.setAttribute('aria-label', t('Agent details')); renderRows(); renderDetail();
+ }
+ const sync = createChatSync({ baseDelay: 15000, random: () => 0,
+ refresh: async current => { const result = await api('/agents/receiving'); if (current()) { data = result; uncertain = false; } },
+ onState: value => { syncState = value; if (['retrying', 'offline', 'paused'].includes(value.status) || value.status === 'idle' && data) uncertain = true; render(); }, onUnauthorized
+ });
+ return { start() { void sync.start().catch(() => {}); }, stop: sync.stop,
+ reset() { uncertain = false; data = null; selected = null; filter = 'all'; search.value = ''; sync.stop(); },
+ availabilityChanged: sync.availabilityChanged, render };
+}
diff --git a/scripts/openapi.mjs b/scripts/openapi.mjs
index 53ee506..9688c4e 100644
--- a/scripts/openapi.mjs
+++ b/scripts/openapi.mjs
@@ -45,6 +45,13 @@ route('/realtime/ticket', 'post', 'Create a short-lived notification connection
route('/probe', 'get', 'Check pending work without downloading messages or thread context', ['agent'], null, obj({agent:obj({id,name}),pending:{type:'boolean'},membership_cursor:s()}), {description:'Authenticated Agent-only idle check. membership_cursor tracks the latest membership insertion for this principal. No message is acknowledged. Supported membership additions include joins to older threads; external SQL deletions require local cache invalidation.'});
route('/me', 'get', 'Read the authenticated identity', all, null, obj({ principal: ref('Principal') }));
route('/me', 'patch', 'Register or update your own Agent name', ['agent'], obj({name}), obj({principal:ref('Principal')}));
+route('/agents/receiving', 'get', 'Observe Agent receiving status', owner, null, obj({
+ items: arr(obj({ id, name, active: { enum: [0, 1] }, last_checked_at: nullableDate, last_sent_at: nullableDate, last_ack_at: nullableDate,
+ pending_count: { type: 'integer', minimum: 0 }, oldest_pending: { anyOf: [{ type: 'null' }, obj({ id, seq: { type: 'integer' }, thread_id: id, created_at: date })] },
+ notification: obj({ state: { enum: ['connected', 'connecting', 'stale', 'disconnected', 'unknown', 'unsupported', 'disabled'] }, connections: { type: ['integer', 'null'], minimum: 0 }, last_heartbeat_at: nullableDate }),
+ receiving_mode: { type: 'null' }, fallback_interval_seconds: { type: 'null' }
+ })), observed_at: date, check_resolution_seconds: { const: 30 }
+}), { description: 'Owner only. No message bodies or credentials; reading never acknowledges messages. Connection state is distinct from model availability. Host schedules are not reported. Inbox checks are coalesced within 30 seconds. Requires migration 0015.' });
route('/principals', 'get', 'Discover instance humans and agents', all, null, obj({ items: arr(ref('Principal')) }));
route('/agents', 'post', 'Create an Agent and initial access key', owner, obj({ name:s(80,0), description: s(500, 0) }, []), obj({ principal: ref('Principal'), name_required:{type:'boolean'}, token: ref('Token') }), { status: 201 });
const pairingState = obj({id,status:{enum:['invited','pending','approved','rejected']},verification_code:{type:['string','null']},expires_at:date});
diff --git a/scripts/receiving-browser-check.mjs b/scripts/receiving-browser-check.mjs
new file mode 100644
index 0000000..321eab1
--- /dev/null
+++ b/scripts/receiving-browser-check.mjs
@@ -0,0 +1,86 @@
+// Disposable Cloudflare runtime + real browser. No production profile or credentials.
+import {build} from 'esbuild';
+import {Miniflare,convertV4MiniflareOptions} from 'miniflare';
+import {readFile,readdir,mkdir} from 'node:fs/promises';
+import {resolve} from 'node:path';
+import {execFile} from 'node:child_process';
+import {promisify} from 'node:util';
+import assert from 'node:assert/strict';
+const exec=promisify(execFile),session='receiving-'+crypto.randomUUID(),sockets=[];
+const browse=async(...args)=>(await exec('./node_modules/.bin/agent-browser',['--session',session,...args],{timeout:30000})).stdout.trim();
+const value=async code=>JSON.parse(await browse('eval',code));
+const wait=async code=>browse('wait','--fn',code);
+let mf;
+try {
+ const bundle=await build({entryPoints:['src/cloudflare.ts'],bundle:true,format:'esm',platform:'browser',external:['cloudflare:workers'],write:false});
+ const secret=crypto.randomUUID();
+ mf=new Miniflare(convertV4MiniflareOptions({workers:[{name:'receiving-browser',modules:true,script:bundle.outputFiles[0].text,compatibilityDate:'2026-10-02',d1Databases:{DB:'receiving-browser'},durableObjects:{NOTIFICATIONS:{className:'MailboxHub',useSQLite:true}},bindings:{SETUP_SECRET:secret},serviceBindings:{ASSETS:async request=>{
+ const name=new URL(request.url).pathname.slice(1)||'index.html';
+ if(!/^(?:[\w.-]+|avatars\/[\w.-]+)$/.test(name))return new Response('',{status:404});
+ try {return new Response(await readFile('public/'+name),{headers:{'Content-Type':name.endsWith('.js')?'text/javascript':name.endsWith('.css')?'text/css':name.endsWith('.svg')?'image/svg+xml':'text/html'}});}catch{return new Response('',{status:404});}
+ }}}]}));
+ const db=await mf.getD1Database('DB');
+ for(const file of (await readdir('migrations')).filter(f=>f.endsWith('.sql')).sort())await db.exec((await readFile('migrations/'+file,'utf8')).replaceAll('\n',' '));
+ const base=(await mf.ready).origin;let owner;
+ const api=async(path,key=owner,method='GET',body)=>{
+ const response=await fetch(base+'/api/v1'+path,{method,headers:{...(key?{Authorization:'Bearer '+key}:{}),'Content-Type':'application/json','Idempotency-Key':crypto.randomUUID()},body:body===undefined?undefined:JSON.stringify(body)});
+ assert.ok(response.ok,'fixture API '+path+' returned '+response.status);return response.json();
+ };
+ owner=(await api('/setup',null,'POST',{name:'Owner',setup_secret:secret})).access_key;
+ const agents=[];
+ for(const name of ['Codex-Mac','WorkBuddy','Muse','GrokBot','Test Agent']){
+ const created=await api('/agents',owner,'POST',{name});
+ const a={id:created.principal.id,key:created.token.token};agents.push(a);
+ if(name==='Test Agent'){await api('/principals/'+a.id,owner,'PATCH',{active:false});continue;}
+ const msg=(await api('/messages',owner,'POST',{to:[a.id],type:'text',content:'Local receiving verification'})).message;
+ if(name!=='GrokBot'){
+ await api('/probe',a.key);await api('/messages',a.key,'POST',{thread_id:msg.thread_id,type:'text',content:'Verified local reply',reply_to:msg.id});await api('/messages/'+msg.id+'/ack',a.key,'POST');
+ }
+ }
+ for(const a of agents.slice(0,2)){
+ const {ticket}=await api('/realtime/ticket',a.key,'POST');
+ const ws=new WebSocket(base.replace('http:','ws:')+'/api/v1/events',['apc-v1','ticket.'+ticket]);sockets.push(ws);
+ await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('WebSocket heartbeat timed out')),10000);ws.addEventListener('message',e=>{if(e.data==='pong'){clearTimeout(timeout);resolve();}else if(JSON.parse(e.data).type==='ready')ws.send('ping');});ws.addEventListener('error',reject);});
+ }
+ await browse('set','viewport','1600','1000');await browse('open',base);
+ await value(`fetch('/api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_key:${JSON.stringify(owner)}})}).then(r=>r.ok)`);
+ await browse('reload');await wait('!document.querySelector("#shell").hidden');
+ await browse('click','.workspace-nav [data-view="receiving"]');
+ await wait('document.querySelectorAll("#receiving tbody tr").length===5');
+ assert.deepEqual(await value('[...document.querySelectorAll(".receiving-stat strong")].map(n=>n.textContent)'),['4','2','1','0']);
+ await browse('select','[data-language-switch]','zh');
+ await browse('click',`[data-receiving-agent="${agents[3].id}"]`);
+ assert.equal(await value('document.querySelector(".receiving-detail h2").textContent'),'GrokBot');
+ assert.match(await value('document.querySelector(".receiving-detail").textContent'),/未上报/);
+ assert.equal(await value('document.documentElement.scrollWidth>innerWidth'),false);
+ if(process.env.AGENTPEERCHAT_SCREENSHOT_DIR){await mkdir(process.env.AGENTPEERCHAT_SCREENSHOT_DIR,{recursive:true});await browse('screenshot',resolve(process.env.AGENTPEERCHAT_SCREENSHOT_DIR,'receiving-desktop-zh.png'));}
+ await browse('click','[data-receiving-filter="attention"]');assert.equal(await value('document.querySelectorAll("#receiving tbody tr").length'),1);
+ await browse('click','[data-receiving-filter="disabled"]');assert.equal(await value('document.querySelectorAll("#receiving tbody tr").length'),1);
+ await browse('click','[data-receiving-filter="all"]');await browse('fill','.receiving-toolbar input','Muse');assert.equal(await value('document.querySelectorAll("#receiving tbody tr").length'),1);
+ await browse('fill','.receiving-toolbar input','');
+ await browse('set','viewport','390','844');assert.equal(await value('document.documentElement.scrollWidth>innerWidth'),false);
+ if(process.env.AGENTPEERCHAT_SCREENSHOT_DIR)await browse('screenshot',resolve(process.env.AGENTPEERCHAT_SCREENSHOT_DIR,'receiving-mobile-zh.png'));
+ await browse('set','viewport','1600','1000');
+ // A failed refresh must not leave green connectivity badges from an old snapshot.
+ await value(`window.originalFetch=window.fetch;window.fetch=(url,options)=>String(url).includes('/agents/receiving')?Promise.reject(new Error('simulated outage')):window.originalFetch(url,options);true`);
+ await browse('click','.receiving-actions button');await wait('!document.querySelector(".receiving-error").hidden');
+ assert.equal(await value('document.querySelectorAll(".receiving-transport.connected").length'),0);
+ await value('window.fetch=window.originalFetch;true');await browse('click','.receiving-actions button');
+ await wait('document.querySelectorAll(".receiving-transport.connected").length===2');
+ await browse('click','[data-receiving-action="open"]');await wait('!document.querySelector("#chat-active").hidden');
+ assert.equal(await value('document.querySelector("#receiving").hidden'),true);
+ await browse('select','[data-language-switch]','en');await browse('click','.workspace-nav [data-view="receiving"]');
+ await wait('document.querySelectorAll("#receiving tbody tr").length===5');
+ assert.equal(await value('document.querySelector("#receiving h1").textContent'),'Agent receiving status');
+ // Non-owner human session: Agents cannot establish browser sessions.
+ const invite=(await api('/invites',owner,'POST',{})).invite;
+ const human=(await api('/invites/redeem',null,'POST',{code:invite.code,name:'Teammate'})).access_key;
+ await value(`fetch('/api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_key:${JSON.stringify(human)}})}).then(r=>r.ok)`);
+ await browse('reload');await wait('!document.querySelector("#shell").hidden');
+ assert.equal(await value('[...document.querySelectorAll("[data-view=receiving]")].every(n=>n.hidden)'),true);
+ assert.equal(await value('fetch("/api/v1/agents/receiving").then(r=>r.status)'),403);
+ console.log('Receiving UI verified: live sockets + heartbeats, backlog, ACK, bilingual desktop/mobile, filters, detail navigation, failure recovery and owner-only access.');
+} finally {
+ try{await browse('close');}catch{}
+ for(const socket of sockets)socket.close();await mf?.dispose();
+}
diff --git a/src/index.ts b/src/index.ts
index 1cb7265..e465c98 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,5 @@
import { realtimeTicket, realtimeConnect, notifyAgents } from './realtime';
+import { receivingStatus, recordInboxCheck } from './receiving';
import { avatarValue } from './avatar';
import { pairingPublic, pairingOwner } from './pairing';
import { ownerLoginRedeem, ownerLoginAgent } from './owner-login';
@@ -102,6 +103,10 @@ async function api(request: Request, env: Env, url: URL) {
const ownerLoginResponse = await ownerLoginAgent(request, env, path, p, identity.tokenId, identity.tokenKind);
if (ownerLoginResponse) return ownerLoginResponse;
const store = new D1MessageStore(env.DB), method = request.method;
+ if (path === '/agents/receiving' && method === 'GET') {
+ owner(p);
+ return json(await receivingStatus(env));
+ }
if (path === '/realtime/ticket' && method === 'POST') {
if (p.kind !== 'agent' || identity.tokenKind !== 'access') fail(403, 'agent_required', 'Use a connected Agent identity.');
return json(await realtimeTicket(env, p.id, identity.tokenId), 201);
@@ -119,6 +124,7 @@ async function api(request: Request, env: Env, url: URL) {
EXISTS (SELECT 1 FROM deliveries WHERE recipient_id = ? AND acked_at IS NULL) AS pending,
(SELECT CAST(COALESCE(MAX(rowid), 0) AS TEXT) FROM thread_members WHERE principal_id = ?) AS membership_cursor`)
.bind(p.id, p.id).first<{ pending: number; membership_cursor: string }>();
+ await recordInboxCheck(env, p.id);
return json({ agent: { id: p.id, name: p.name }, pending: !!probe?.pending, membership_cursor: probe!.membership_cursor });
}
if (path === '/me' && method === 'PATCH') {
@@ -391,7 +397,9 @@ async function api(request: Request, env: Env, url: URL) {
if (path === '/inbox' && method === 'GET') {
const { after, limit } = pagination(url), include = url.searchParams.get('include_acked');
if (include !== null && !['0', '1'].includes(include)) fail(400, 'invalid_field', 'include_acked must be 0 or 1.');
- return json(await store.listInbox(p.id, after, limit, include === '1'));
+ const inbox = await store.listInbox(p.id, after, limit, include === '1');
+ if (p.kind === 'agent') await recordInboxCheck(env, p.id);
+ return json(inbox);
}
match = /^\/messages\/([^/]+)(\/ack)?$/.exec(path);
if (match) {
diff --git a/src/mailbox-hub.ts b/src/mailbox-hub.ts
index 13ea248..f868b50 100644
--- a/src/mailbox-hub.ts
+++ b/src/mailbox-hub.ts
@@ -1,6 +1,7 @@
import { DurableObject } from 'cloudflare:workers';
import type { Env } from './types';
import { validStreamKey } from './realtime';
+import { notificationSnapshot } from './receiving';
// One notification coordinator per principal. All durable messages remain in D1.
export class MailboxHub extends DurableObject