Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
9 changes: 9 additions & 0 deletions docs/local-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
28 changes: 28 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions migrations/0015_agent_receiving_activity.sql
Original file line number Diff line number Diff line change
@@ -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);
21 changes: 20 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 1 addition & 2 deletions public/chat-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion public/i18n.js
Original file line number Diff line number Diff line change
@@ -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]));
Expand Down
Loading
Loading