Skip to content

feat(sdk/plugin-claude): surface incoming contact requests for approval#207

Merged
sanil-23 merged 2 commits into
tinyhumansai:mainfrom
sanil-23:feat/plugin-contact-surfacing
Jul 2, 2026
Merged

feat(sdk/plugin-claude): surface incoming contact requests for approval#207
sanil-23 merged 2 commits into
tinyhumansai:mainfrom
sanil-23:feat/plugin-contact-surfacing

Conversation

@sanil-23

@sanil-23 sanil-23 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #204 (merged). That squash landed everything through the review fixes, but this commit — incoming contact-request surfacing — was pushed just after the merge, so it's not in main yet.

What

The plugin daemon previously only polled /messages, so an incoming contact request never reached the other agent's Claude session — it had to be pulled manually with contact_requests. Now the listener also polls /contacts/requests (every 15s) and, on a new incoming request:

  • pushes it into the session as a <channel source="tinyplace"> event (meta.kind="contact_request"),
  • exposes it in whoami.pendingContactRequests and inbox.contactRequests,
  • is approvable with the existing contact_accept tool or the new /tinyplace:contacts command.

Never auto-accepted — accepting a contact is a trust decision (and the DM gate depends on it), unlike the auto-responder which only answers existing contacts.

Verified (staging)

alice sends a contact request → it surfaces in bob's whoami.pendingContactRequests and inbox.contactRequests (bob does not auto-accept).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for viewing incoming contact requests alongside accepted contacts.
    • Added a command flow to approve a specific contact request manually.
    • Expanded inbox and account info responses to include pending contact-request details.
  • Bug Fixes

    • Contact requests are now surfaced through notifications more reliably.
    • Incoming requests are handled separately from direct messages to avoid accidental acceptance.

The daemon only polled /messages, so an incoming contact request never reached
the other agent's session — it had to be pulled manually. Now the listener also
polls /contacts/requests (every 15s) and, on a NEW incoming request, pushes it
into the session as a channel event and exposes it in whoami.pendingContactRequests
and inbox.contactRequests. The agent approves with contact_accept (or the new
/tinyplace:contacts command). Never auto-accepted — accepting a contact is a trust
decision (and the DM gate depends on it), unlike the auto-responder which only
answers existing contacts.

Verified live on staging: alice's contact request surfaces in bob's whoami/inbox.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

@sanil-23 is attempting to deploy a commit to the Vezures Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e8385dbc-6a90-4db9-9e01-04ddcbb06ab6

📥 Commits

Reviewing files that changed from the base of the PR and between df47a62 and bfe6730.

📒 Files selected for processing (1)
  • sdk/plugin-claude/mcp/server.mjs
👮 Files not reviewed due to content moderation or server errors (1)
  • sdk/plugin-claude/mcp/server.mjs

📝 Walkthrough
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: surfacing incoming contact requests for approval in sdk/plugin-claude.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
sdk/plugin-claude/mcp/server.mjs (1)

924-931: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

inbox now blocks on an extra network round-trip every call.

await drainContacts() performs a full contacts.requests() fetch synchronously on every inbox invocation, in addition to the 15s background poll. This adds latency/failure surface to a hot path tool without a visible timeout on the underlying HTTP client. Consider relying on the cached pendingContactRequests populated by the background timer (or only re-fetching if the cache is older than some threshold) rather than fetching inline on every inbox call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/plugin-claude/mcp/server.mjs` around lines 924 - 931, The inbox flow in
server.mjs is doing an inline contacts.requests() fetch via drainContacts on
every call, adding unnecessary latency and failure risk to a hot path. Update
the inbox handling to rely on the cached pendingContactRequests maintained by
the background poll, or only refresh it when the cache is stale beyond a
threshold, and remove the unconditional await drainContacts() from the inbox
path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdk/plugin-claude/mcp/server.mjs`:
- Around line 511-525: The `drainContacts` flow in `server.mjs` leaves
`seenContactRequests` permanently populated, so withdrawn or later re-sent
requests from the same requester never call `pushContactRequest` again. Update
`drainContacts` to reconcile `session.seenContactRequests` against the current
`session.pendingContactRequests` on each tick: keep only requesters still
present, remove any no-longer-pending entries, and then continue pushing
notifications for newly pending requesters. Use the existing
`pendingContactRequests`, `seenContactRequests`, and `pushContactRequest`
symbols to keep the notification state consistent.

---

Nitpick comments:
In `@sdk/plugin-claude/mcp/server.mjs`:
- Around line 924-931: The inbox flow in server.mjs is doing an inline
contacts.requests() fetch via drainContacts on every call, adding unnecessary
latency and failure risk to a hot path. Update the inbox handling to rely on the
cached pendingContactRequests maintained by the background poll, or only refresh
it when the cache is stale beyond a threshold, and remove the unconditional
await drainContacts() from the inbox path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e6acee24-2a7d-4209-868a-13c91a9bcd1b

📥 Commits

Reviewing files that changed from the base of the PR and between d641e1b and df47a62.

📒 Files selected for processing (2)
  • sdk/plugin-claude/commands/contacts.md
  • sdk/plugin-claude/mcp/server.mjs

Comment on lines +511 to +525
async function drainContacts() {
if (!session) return;
try {
const { incoming } = await session.client.contacts.requests();
const list = Array.isArray(incoming) ? incoming : [];
session.pendingContactRequests = list.map((r) => String(r.agentId ?? r.contact?.requester ?? "")).filter(Boolean);
for (const requester of session.pendingContactRequests) {
if (session.seenContactRequests.has(requester)) continue;
session.seenContactRequests.add(requester);
void pushContactRequest(requester);
}
} catch {
// best-effort — pending list simply doesn't update this tick
}
}

@coderabbitai coderabbitai Bot Jul 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stale seenContactRequests blocks re-notification after withdraw/resend.

seenContactRequests entries are never removed, so if a requester is withdrawn (or accepted then a new request from the same peer arrives later), it won't re-trigger pushContactRequest — it will only silently reappear in pendingContactRequests/inbox/whoami without the channel push. Consider reconciling the set with the current pendingContactRequests each tick so requesters no longer pending get removed and can trigger a fresh notification if they re-request.

🔧 Suggested fix
     const { incoming } = await session.client.contacts.requests();
     const list = Array.isArray(incoming) ? incoming : [];
     session.pendingContactRequests = list.map((r) => String(r.agentId ?? r.contact?.requester ?? "")).filter(Boolean);
+    const current = new Set(session.pendingContactRequests);
+    for (const seen of session.seenContactRequests) {
+      if (!current.has(seen)) session.seenContactRequests.delete(seen);
+    }
     for (const requester of session.pendingContactRequests) {
       if (session.seenContactRequests.has(requester)) continue;
       session.seenContactRequests.add(requester);
       void pushContactRequest(requester);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function drainContacts() {
if (!session) return;
try {
const { incoming } = await session.client.contacts.requests();
const list = Array.isArray(incoming) ? incoming : [];
session.pendingContactRequests = list.map((r) => String(r.agentId ?? r.contact?.requester ?? "")).filter(Boolean);
for (const requester of session.pendingContactRequests) {
if (session.seenContactRequests.has(requester)) continue;
session.seenContactRequests.add(requester);
void pushContactRequest(requester);
}
} catch {
// best-effort — pending list simply doesn't update this tick
}
}
async function drainContacts() {
if (!session) return;
try {
const { incoming } = await session.client.contacts.requests();
const list = Array.isArray(incoming) ? incoming : [];
session.pendingContactRequests = list.map((r) => String(r.agentId ?? r.contact?.requester ?? "")).filter(Boolean);
const current = new Set(session.pendingContactRequests);
for (const seen of session.seenContactRequests) {
if (!current.has(seen)) session.seenContactRequests.delete(seen);
}
for (const requester of session.pendingContactRequests) {
if (session.seenContactRequests.has(requester)) continue;
session.seenContactRequests.add(requester);
void pushContactRequest(requester);
}
} catch {
// best-effort — pending list simply doesn't update this tick
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/plugin-claude/mcp/server.mjs` around lines 511 - 525, The `drainContacts`
flow in `server.mjs` leaves `seenContactRequests` permanently populated, so
withdrawn or later re-sent requests from the same requester never call
`pushContactRequest` again. Update `drainContacts` to reconcile
`session.seenContactRequests` against the current
`session.pendingContactRequests` on each tick: keep only requesters still
present, remove any no-longer-pending entries, and then continue pushing
notifications for newly pending requesters. Use the existing
`pendingContactRequests`, `seenContactRequests`, and `pushContactRequest`
symbols to keep the notification state consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bfe6730: drainContacts now reconciles seenContactRequests against the live pending set each tick, so a withdrawn/re-sent request from the same peer re-triggers pushContactRequest instead of silently reappearing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, something went wrong! Please try again later. 🐰 💔

- drainContacts: reconcile seenContactRequests against the live pending set
  each tick so a withdrawn/re-sent request from the same peer re-triggers a
  channel notification instead of silently reappearing in whoami/inbox.
- inbox: drop the inline drainContacts() network round-trip; read the cache
  maintained by the 15s background poll (matching whoami) to keep the hot
  path off an extra fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sanil-23

sanil-23 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the CodeRabbit review in bfe6730:

  • Major — stale seenContactRequests (server.mjs:511-525): drainContacts now reconciles the seen-set against the live pendingContactRequests each tick; requesters no longer pending are dropped, so a withdrawn/re-sent request from the same peer re-triggers the channel notification.
  • Nitpick — inbox inline round-trip (server.mjs:924): removed the unconditional await drainContacts() from the inbox hot path. It now reads the cache maintained by the 15s background poll (same as whoami), avoiding an extra per-call network fetch.

Verified with node --check plus the offline regressions (smoke-test.mjs, assignment-test.mjs, env-adopt-test.mjs) — all pass.

@sanil-23 sanil-23 merged commit 0b52b4c into tinyhumansai:main Jul 2, 2026
9 of 10 checks passed
sanil-23 added a commit that referenced this pull request Jul 2, 2026
…e C)

Give each agent a single process that owns the relay drain + Signal ratchet, so
N sessions never race the mailbox or corrupt the shared ratchet.

- hooks/agent-daemon.mjs: single owner via a CAS lock (mcp/daemon-lock.mjs) with
  takeover on death and idle-exit when the agent has no live sessions. It is the
  sole decryptor — drains the mailbox once, routes each message to the target
  session's inbox/ queue (to_session live → that session; dead → _unrouted/ held
  until it appears; none → primary/broadcast/drop per TINYPLACE_UNROUTED_POLICY),
  sends outbound jobs from _outbox/ (sole ratchet writer), and drives the
  auto-responder.
- mcp/routing.mjs (routeTarget/enqueueRouted/drainInbox/redeliverUnrouted) and
  mcp/outbox.mjs (writeOutboxJob/claimOutboxJobs) — atomic-rename file queues.
- server.mjs thin-client mode: on use, register presence + ensure a daemon; if
  one is live, stop touching the relay — send/auto_reply write outbox jobs,
  inbox/check_reply/await_reply/send_and_wait read the session's inbox/ queue
  (correlating on the in-body envelope message.id). Falls back to the original
  self-drain if the daemon is disabled (TINYPLACE_SESSION_DAEMON=off) or can't
  start — no regression for single-session use. whoami reports mode + daemon.
- format.mjs: buildEnvelope returns the message id; decode surfaces messageId.
- Tests: routing-test.mjs, lock-test.mjs (incl. a 3-way process race), and
  daemon-test.mjs (daemon boot + idle-exit + thin-client send/inbox/check_reply)
  wired into npm test. Existing adopt tests pin TINYPLACE_SESSION_DAEMON=off.

Follow-up: contact-request surfacing (drainContacts / /tinyplace:contacts) is not
in this base (PR #207), so moving contact-polling into the daemon is deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant