diff --git a/README.md b/README.md index 849781f..9031e80 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,10 @@ The directory supports up to 200 principals, groups up to 32 members, text messa ### Efficient receiving and suspended runtimes +**Receiving status (Owner only):** open the pulse icon in the workspace navigation to inspect notification connections and heartbeat freshness, recent inbox checks, pending deliveries and their oldest age, and the last sent message/ACK. The page refreshes every 15 seconds while visible. Connection failures are shown as unknown, not offline. A connected socket does not guarantee a model is running; an ACK confirms handling, not task correctness. Host schedules and fallback intervals remain “Not reported” until a host integration supplies them. Node/SQLite reports realtime notifications as unsupported while retaining inbox/ACK diagnostics. + +Apply migration `0015_agent_receiving_activity.sql` before deploying this page. Successful Agent `/probe` and `/inbox` calls record check times with a 30-second write coalescing window; old check times cannot be reconstructed. No CLI update or new Agent identity is required. + The listener uses one cheap authenticated probe per idle tick on updated servers. It starts the model only when pending messages or membership changes exist; older servers use the higher-cost summary fallback. Your chosen check interval is preserved. A 100-item page is not a mailbox limit: `inbox` drains all pages, while `inbox --page [--after CURSOR]` lets a runtime process bounded chunks. Inbox and thread message pages also have a 256 KiB message-data budget, and cursors never skip the omitted tail. Messages survive suspension until explicitly acknowledged. Waking a hosted agent needs that platform's supported scheduled turn/event entry point; installing a skill or external poller alone cannot wake a suspended hosted dot/Muse conversation. See the [automation guide](skills/agentpeerchat/references/automation.md) for restart, fallback, and status semantics. diff --git a/README.zh-CN.md b/README.zh-CN.md index 35cac05..9c72fde 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -301,6 +301,10 @@ flowchart TB ### 低成本接收与暂停恢复 +**接收状态(仅 Owner):** 点击工作区导航中的心跳图标,查看通知连接与心跳新鲜度、最近收件检查、待处理消息及最早等待时间,以及最近发送和 ACK。页面可见时每 15 秒刷新;查询失败显示“未知”,不会直接认定离线。连接正常不保证模型已运行,ACK 表示已处理,不保证任务结果正确。宿主定时任务和兜底间隔在没有上报时显示“未上报”。Node/SQLite 部署的实时通知显示“不支持”,收件检查和 ACK 诊断仍然可用。 + +部署前应用迁移 `0015_agent_receiving_activity.sql`。Agent 成功调用 `/probe` 或 `/inbox` 后记录检查时间,30 秒内合并写入;历史检查时间不能补录。不需要升级 CLI 或重新创建 Agent 身份。 + 新版服务端的监听器在空闲时每次只发送一个轻量、经过身份验证的 probe 请求;有待处理消息或成员关系变化时才启动模型。旧服务端会回退到开销较大的完整 summary,检查间隔不会暗中变慢。每页 100 条不是邮箱总量上限:`inbox` 会继续翻页读完;`inbox --page [--after CURSOR]` 支持按批处理。收件箱和聊天消息页还限制约 256 KiB 的序列化消息数据,游标只越过实际返回的记录,不丢弃后续消息。 暂停期间消息仍保留,只有成功处理后显式 ack 才移出待处理队列。托管 Agent 的唤醒需要其平台支持的定时 Agent 回合或事件入口;安装 skill 或外部轮询器本身不能唤醒被暂停的 dot/Muse 对话。恢复、旧版本回退和状态含义见[自动化指南](skills/agentpeerchat/references/automation.md)。 diff --git a/docs/local-testing.md b/docs/local-testing.md index 05c0e78..daf16a6 100644 --- a/docs/local-testing.md +++ b/docs/local-testing.md @@ -71,3 +71,12 @@ Codex 使用本机已登录的 ChatGPT 配置。此机原先没有 Claude Code 本机直连 `workers.dev` 会失败,配置代理后正常;自定义域名直连正常。客户端已修复 Node fetch 默认不读取代理环境变量的问题,Node.js 24+ 普通 `node agentpeerchat.mjs ...` 命令会自动使用已有 HTTP(S) 代理。没有代理且网络不能直连时,客户端不会凭空解决网络限制。 手动重跑使用 `scripts/test-live-agents.mjs`,通过凭据管理器为进程提供 `AGENTPEERCHAT_URL`、`AGENTPEERCHAT_OWNER_TOKEN` 和模型凭据;使用 GLM 时提供 `GLM_CODING_PLAN_API_KEY`。`AGENTPEERCHAT_CLAUDE_CLI` 可指向临时安装的 Claude Code。该测试会创建两个身份、一个群和测试消息,并禁用测试身份,不能用于没有变更授权的实例。结果保存为 `docs/live-agents-verification.json`。一次验证不代表两个客户端已常驻轮询。 + +## Receiving dashboard + +Run `node scripts/receiving-browser-check.mjs` for an isolated Cloudflare Durable +Object/D1 instance and real browser checks (requires the installed agent-browser +Chromium). It exercises live WebSocket heartbeats, backlog and ACK, owner-only +access, English/Chinese, desktop/mobile layouts, filtering, details, navigation +and failed-refresh recovery. Set `AGENTPEERCHAT_SCREENSHOT_DIR` to a local output +directory to keep screenshots; no production profile or messages are used. diff --git a/docs/protocol.md b/docs/protocol.md index f5c3e46..d198ade 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -177,6 +177,34 @@ Message creation, optional thread creation, recipient deliveries, and thread cou `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 diff --git a/migrations/0015_agent_receiving_activity.sql b/migrations/0015_agent_receiving_activity.sql new file mode 100644 index 0000000..1ff0547 --- /dev/null +++ b/migrations/0015_agent_receiving_activity.sql @@ -0,0 +1,6 @@ +CREATE TABLE agent_receiving_activity ( + principal_id TEXT PRIMARY KEY REFERENCES principals(id), + last_checked_at TEXT NOT NULL +); +CREATE INDEX messages_sender_seq ON messages(sender_id, seq); +CREATE INDEX deliveries_recipient_ack ON deliveries(recipient_id, acked_at); diff --git a/public/app.js b/public/app.js index f089c19..a3b3242 100644 --- a/public/app.js +++ b/public/app.js @@ -4,6 +4,7 @@ import { threadPeople, visibleThreads, conversationGraph, isDisabled, avatarIcon import { renderConversationGraph } from './conversation-graph.js'; import { connectionInstructions } from './connection-kit.js'; import { createChatSync } from './chat-sync.js'; +import { createReceivingPage } from './receiving-status.js'; initLanguage(); const $ = selector => document.querySelector(selector); const appBase = document.querySelector('meta[name="agentpeerchat-base"]')?.content ?? document.querySelector('meta[name="agentgram-base"]')?.content ?? ''; @@ -37,6 +38,7 @@ async function allPages(path, initial = '0') { } function authMode(mode) { chatSync.stop(); + receivingPage.reset(); state.authMode = mode; $('.skip-link').href = '#onboarding'; $('#perspective-toolbar').hidden = true; $('#menu-toggle').hidden = true; $('#onboarding').hidden = false; $('#shell').hidden = true; $('#auth-error').textContent = ''; const setup = mode === 'setup', invite = mode === 'invite'; @@ -137,7 +139,9 @@ async function enter() { $('#my-name').textContent = state.me.name; $('#my-role').textContent = state.me.kind === 'owner' ? t("拥有者") : t("联系人"); $('#my-avatar').textContent = state.me.name.slice(0, 1).toUpperCase(); $('#composer-name').textContent = state.me.name; document.querySelectorAll('.owner-only').forEach(node => { node.hidden = state.me.kind !== 'owner'; }); + if (state.view === 'receiving' && state.me.kind !== 'owner') showView('conversations'); await chatSync.start(); + if (state.view === 'receiving' && state.me.kind === 'owner') receivingPage.start(); await consumePendingThread(); } // A deep link (#thread=…) survives manual sign-in: the target is held until a session exists. @@ -170,19 +174,33 @@ const chatSync = createChatSync({ refresh: refreshData, onState: renderSyncState, onUnauthorized: () => { state.me = null; authMode('login'); toast(t("Session ended. Sign in again.")); } }); +const receivingPage = createReceivingPage({ root: $('#receiving'), api, t, locale: getLocale, + avatar: p => personAvatar(state.principals.find(person => person.id === p.id) ?? p), + onUnauthorized: () => { state.me = null; authMode('login'); toast(t('Session ended. Sign in again.')); }, + openConversation: async p => { + const id = p.oldest_pending?.thread_id ?? state.threads.find(thread => thread.kind === 'direct' && threadPeople(thread).some(person => person.id === p.id) && threadPeople(thread).some(person => person.id === state.me.id))?.id; + if (!id) { toast(t('No direct conversation with this Agent yet.')); return; } + showView('conversations'); await selectThread(id); + } +}); function refresh() { return chatSync.refresh(); } document.addEventListener('visibilitychange', chatSync.availabilityChanged); +document.addEventListener('visibilitychange', receivingPage.availabilityChanged); +window.addEventListener('online', receivingPage.availabilityChanged); +window.addEventListener('offline', receivingPage.availabilityChanged); window.addEventListener('online', chatSync.availabilityChanged); window.addEventListener('offline', chatSync.availabilityChanged); $('#logout').addEventListener('click', async () => { try { await api('/session', { method: 'DELETE' }); state.me = null; state.selected = null; state.messages = []; $('#chat-empty').hidden = false; $('#chat-active').hidden = true; authMode('login'); } catch (error) { toast(error.message); } }); function showView(view) { + if (view === 'receiving' && state.me?.kind !== 'owner') return; state.view = view; $('.skip-link').href = `#${view}`; toggleMenu(false); - for (const name of ['conversations', 'network', 'access', 'graph']) $(`#${name}`).hidden = name !== view; + for (const name of ['conversations', 'network', 'access', 'graph', 'receiving']) $(`#${name}`).hidden = name !== view; document.querySelectorAll('[data-view]').forEach(button => { const active = button.dataset.view === view; button.classList.toggle('active', active); button.setAttribute('aria-current', active ? 'page' : 'false'); }); if (view === 'graph') renderGraph(); if (view === 'access') loadAccess().catch(error => toast(error.message)); + if (view === 'receiving') receivingPage.start(); else receivingPage.stop(); } document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', () => showView(button.dataset.view))); $('#thread-search').addEventListener('input', renderThreads); @@ -768,6 +786,7 @@ document.addEventListener("agentpeerchat:languagechange",()=>{ if(state.inviteCode){void showAgentInvitation(state.inviteCode);return;} if(!state.me){authMode(state.authMode);return;} renderSyncState(chatSync.snapshot()); + receivingPage.render(); $("#my-role").textContent=state.me.kind==="owner"?t("拥有者"):t("联系人"); renderPerspective();renderThreads();renderPrincipals();renderMembers();renderMessages();renderGraph(); if(state.perspective!=="all")$("#observe-actions span").textContent=t("Viewing as {0} · Read only",humanName(state.perspective)); diff --git a/public/chat-sync.js b/public/chat-sync.js index 28975e0..7190ee2 100644 --- a/public/chat-sync.js +++ b/public/chat-sync.js @@ -4,9 +4,8 @@ export function createChatSync({ isVisible = () => !document.hidden, isOnline = () => navigator.onLine !== false, setTimer = setTimeout, clearTimer = clearTimeout, - now = Date.now, random = Math.random + now = Date.now, random = Math.random, baseDelay = 30000 }) { - const baseDelay = 30000; let active = false, timer = null, cycle = null; let delay = baseDelay, lastSyncedAt = null, failed = false; const available = () => isVisible() && isOnline(); diff --git a/public/i18n.js b/public/i18n.js index 3b5969c..eb3cd65 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -1,6 +1,7 @@ import { translations as baseTranslations } from './locales.js'; import { profileTranslations } from './profile-locales.js'; -const translations = {...baseTranslations,...profileTranslations}; +import { receivingTranslations } from './receiving-locales.js'; +const translations = {...baseTranslations,...profileTranslations,...receivingTranslations}; const english = new Map(Object.entries(translations)); const chinese = new Map(Object.entries(translations).map(([zh, en]) => [en, zh])); diff --git a/public/index.html b/public/index.html index d688ec4..c8f6512 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,7 @@ AgentPeerChat — Private conversations between your agents + @@ -89,6 +90,7 @@

Join this instance as an Agent

+ @@ -99,6 +101,7 @@

Join this instance as an Agent

+
Y
YouOwner
@@ -114,7 +117,7 @@

Join this instance as an Agent

-
A PLACE TO THINK TOGETHER

Good work starts
with a conversation.

Choose a contact or create a group.
Your agents keep their own tools and identities.

Direct or groupKeep the right context together
Durable inboxesSaved until a recipient acknowledges

Agents need a running listener or a supported scheduler to check messages. Presence is not tracked.

+
A PLACE TO THINK TOGETHER

Good work starts
with a conversation.

Choose a contact or create a group.
Your agents keep their own tools and identities.

Direct or groupKeep the right context together
Durable inboxesSaved until a recipient acknowledges

Agents need a running listener or a supported scheduler to check messages. A connection does not guarantee processing.