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
109 changes: 101 additions & 8 deletions clients/claude-code/hooks-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,44 @@ const BOUND_HTTP_CALLERS = [
'react',
'setChannelDescription',
'setThreadResolved',
'subscribe',
'unreact',
'unsubscribe',
] as const

/**
* Inbox verbs whose adapter implementation reaches `boundHttp` (comms-g5zh.2 /
* .3). Declaring interest writes realm state under the seat's own principal —
* a subscription row and the event queue that delivers against it — so these
* bind for the same reason the publisher verbs do.
*
* Held apart from {@link BOUND_VERBS} because the tool-side trace resolves them
* through a different receiver (`adapter.inbox.*`, not `adapter.publisher.*`).
* That distinction is the whole reason the pre-existing guard could not see
* them: it compared over publisher verbs alone, so a binding inbox verb sat
* outside the compared set entirely and the suite stayed green.
*/
const BOUND_INBOX_VERBS = ['subscribe', 'unsubscribe'] as const

/**
* Tools that reach `boundHttp` through an inbox verb while sitting outside the
* matcher, so the hook never stamps them and the bind seam sees no session id.
*
* EMPTY, and it has to stay that way. An unstamped inbox verb is not a
* `comms-tww6`-style attribution accident — it cannot inherit an earlier
* call's seat, because `boundHttp` consults the binder on every call and
* refuses outright when the context carries no session id. It simply FAILS.
*
* The matcher was widened to the seven tools that declare `session_id`, which
* REVERSES commit `0f0e755` (PR #126) — that commit chose id-blind subscribe
* and explicitly declined to add these two. The reversal is deliberate and
* ratified: `#126`'s choice served the shared-minter architecture, where
* subscribe wrote under the minter and needed no identity of its own.
* `comms-g5zh.3` retires that architecture — subscribe now mints — so the
* premise `#126` rested on is gone.
*/
const G5ZH3_MATCHER_PENDING = [] as const

/**
* Tools that reach `boundHttp` while declaring no `session_id` and sitting
* outside the matcher — the open P1 `comms-tww6`. They run under whatever seat
Expand Down Expand Up @@ -91,28 +126,44 @@ function adapterVerbsReachingBoundHttp(source: string): ReadonlySet<string> {

interface ToolFacts {
readonly verbs: ReadonlySet<string>
readonly inboxVerbs: ReadonlySet<string>
readonly declaresSessionId: boolean
}

/** Per-tool: which publisher verbs its handler calls, and whether it declares `session_id`. */
/**
* Per-tool: which publisher verbs and which inbox verbs its handler calls, and
* whether it declares `session_id`.
*/
function toolFactsFromToolsSource(source: string): ReadonlyMap<string, ToolFacts> {
const facts = new Map<string, { verbs: Set<string>; declaresSessionId: boolean }>()
const facts = new Map<
string,
{ verbs: Set<string>; inboxVerbs: Set<string>; declaresSessionId: boolean }
>()
let current: string | undefined
for (const line of source.split('\n')) {
const named = line.match(/^ {6}name: '([a-z_]+)',$/)?.[1]
if (named !== undefined) {
current = named
facts.set(named, { verbs: new Set(), declaresSessionId: false })
facts.set(named, { verbs: new Set(), inboxVerbs: new Set(), declaresSessionId: false })
}
const entry = current === undefined ? undefined : facts.get(current)
if (entry === undefined) continue
for (const verb of line.matchAll(/adapter\.publisher\.(\w+)/g)) {
const captured = verb[1]
if (captured !== undefined) entry.verbs.add(captured)
}
// Inbox verbs bind too (comms-g5zh.2/.3). Traced separately because the
// receiver differs; tracing only `adapter.publisher.*` is precisely how a
// binding verb stayed outside this suite's compared set.
for (const verb of line.matchAll(/adapter\.inbox\.(\w+)/g)) {
const captured = verb[1]
if (captured !== undefined) entry.inboxVerbs.add(captured)
}
if (line.includes('session_id: sessionIdField')) entry.declaresSessionId = true
}
return new Map([...facts].map(([name, e]) => [name, { ...e, verbs: e.verbs }]))
return new Map(
[...facts].map(([name, e]) => [name, { ...e, verbs: e.verbs, inboxVerbs: e.inboxVerbs }]),
)
}

function alternationToolsFromMatcher(matcher: string): ReadonlySet<string> {
Expand Down Expand Up @@ -175,6 +226,25 @@ test('every tool whose adapter path reaches boundHttp is in the PreToolUse match
expect(missing).toEqual([])
})

// The same rule, stated over the INBOX verbs that began binding with
// comms-g5zh.2/.3. Kept as its own assertion with its own named list so the
// publisher-side rule above cannot go green on a set that no longer covers
// every binding path — the failure mode comms-65nj recorded, where a guard
// filed in May 2026 against exactly this drift stayed green for eight months
// because its compared set was scoped one level too low.
test('the tools that bind via an inbox verb but are unstamped are exactly the recorded set', async () => {
const facts = toolFactsFromToolsSource(await toolsSource())
const matched = alternationToolsFromMatcher(injectSessionIdMatcher(hooksManifest))
const unstamped = [...facts]
.filter(([, f]) =>
[...f.inboxVerbs].some((v) => (BOUND_INBOX_VERBS as ReadonlyArray<string>).includes(v)),
)
.map(([name]) => name)
.filter((name) => !matched.has(name))
.sort()
expect(unstamped).toEqual([...G5ZH3_MATCHER_PENDING])
})

// The rule stated over ALL bound verbs, including the two helper-backed ones
// the tool layer never stamps. This is the assertion `comms-tww6` closes.
test('comms-tww6: the known unstamped bound-path tools are exactly the recorded exceptions', async () => {
Expand All @@ -197,9 +267,13 @@ test('the matcher carries no tool that never reaches boundHttp and never binds',
.filter((name) => name !== 'current_identity')
.filter((name) => {
const f = facts.get(name)
if (f === undefined) return true
// Either receiver counts. A tool binds through the publisher verbs or
// through the inbox verbs; asking only about the first would call a
// legitimately-stamped `subscribe` an orphan.
return (
f === undefined ||
![...f.verbs].some((v) => (BOUND_VERBS as ReadonlyArray<string>).includes(v))
![...f.verbs].some((v) => (BOUND_VERBS as ReadonlyArray<string>).includes(v)) &&
![...f.inboxVerbs].some((v) => (BOUND_INBOX_VERBS as ReadonlyArray<string>).includes(v))
)
})
.sort()
Expand Down Expand Up @@ -230,10 +304,29 @@ test('toolFactsFromToolsSource attributes verbs and session_id to the enclosing
handler: async () => {
await run(adapter.history.readChannel(channel))
},
name: 'gamma',
handler: async () => {
await run(adapter.inbox.subscribe(target))
},
`
const facts = toolFactsFromToolsSource(synthetic)
expect(facts.get('alpha')).toEqual({ verbs: new Set(['post']), declaresSessionId: true })
expect(facts.get('beta')).toEqual({ verbs: new Set(), declaresSessionId: false })
expect(facts.get('alpha')).toEqual({
verbs: new Set(['post']),
inboxVerbs: new Set(),
declaresSessionId: true,
})
expect(facts.get('beta')).toEqual({
verbs: new Set(),
inboxVerbs: new Set(),
declaresSessionId: false,
})
// A read through the inbox is still traced as an inbox verb here; whether it
// BINDS is decided by `BOUND_INBOX_VERBS`, not by the receiver.
expect(facts.get('gamma')).toEqual({
verbs: new Set(),
inboxVerbs: new Set(['subscribe']),
declaresSessionId: false,
})
})

test('alternationToolsFromMatcher splits the trailing parenthesised group', () => {
Expand Down
2 changes: 1 addition & 1 deletion clients/claude-code/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__plugin_commy_commy__(post|edit_message|react|unreact|current_identity)",
"matcher": "mcp__plugin_commy_commy__(post|edit_message|react|unreact|current_identity|subscribe|unsubscribe)",
"hooks": [
{
"type": "command",
Expand Down
21 changes: 19 additions & 2 deletions clients/claude-code/hooks/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,20 @@ import hooks from './hooks.json'

const PLUGIN_SLUG = 'commy'
const EXPECTED_PREFIX = `mcp__plugin_${PLUGIN_SLUG}_${PLUGIN_SLUG}__`
const ATTRIBUTION_TOOLS = [['post'], ['react'], ['unreact'], ['current_identity']] as const
// Every tool the matcher must stamp. `edit_message` was missing here for as
// long as the list was hand-kept; `subscribe` / `unsubscribe` join it because
// declaring interest now mints (comms-g5zh.3). The set-level invariant —
// matcher == the tools whose schema declares session_id — is asserted in
// `hooks-manifest.test.ts`; this list is the per-name spelling check.
const ATTRIBUTION_TOOLS = [
['post'],
['edit_message'],
['react'],
['unreact'],
['current_identity'],
['subscribe'],
['unsubscribe'],
] as const

interface HookEntry {
readonly matcher: string
Expand Down Expand Up @@ -52,7 +65,11 @@ test('matcher does not match arbitrary other MCP tools (no over-broad capture)',
const matcher = preToolUse[0]?.matcher ?? ''
const re = new RegExp(`^${matcher}$`)
expect(re.test('mcp__plugin_discord_discord__reply')).toBe(false)
expect(re.test(`${EXPECTED_PREFIX}subscribe`)).toBe(false)
// `subscribe` used to stand here as the example of a tool the matcher must
// NOT capture. It is now stamped deliberately (comms-g5zh.3 — subscribe
// mints), so the guarantee is restated against a tool that genuinely never
// binds: `read_thread` is a read, and a read never mints.
expect(re.test(`${EXPECTED_PREFIX}read_thread`)).toBe(false)
expect(re.test(`${EXPECTED_PREFIX}list_agents`)).toBe(false)
expect(re.test(`${EXPECTED_PREFIX}list_channels`)).toBe(false)
})
Expand Down
44 changes: 30 additions & 14 deletions docs/agent-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,18 @@ Places the current implementation fails this reference.

How a small optimisation becomes a large architecture.

An ephemeral session does not mint a bot until its first attribution-
producing call, so that a session which never uses commy costs the realm
An ephemeral session did not mint a bot until its first attribution-
producing call, so that a session which never used commy cost the realm
nothing. But a session that has not yet minted still needs to receive — so
something must listen on its behalf. That something is the minter,
something must listen on its behalf. That something was the minter,
subscribed to every public stream, with the event queue registered against
it rather than the per-session bot.

The example is told in the past tense because the first two steps of the
chain have since been taken apart: the queue and the subscriptions now
belong to the seat. The rest of the chain is still standing, and the
paragraphs below say which parts.

Everything else follows from that one deferral. One shared subscriber means
per-agent narrowing cannot be a realm subscription, so it becomes a
client-side filter. A client-side filter over a shared account needs
Expand All @@ -156,17 +161,28 @@ a persistent store. That store has no realm principal to key on, so it keys
on `session_id`. Each step is locally reasonable; the sum is not.

The refcounting step is the one the architecture never took, and its absence
is live today. `streamIsListening` (`packages/zulip/adapter.ts:506-508`) does
refcount — but over the narrow *kinds* one seat holds on a channel
(`channel:X` against `new-topics:X`), within a single `InboxState`. That
state lives behind an `inboxRef` constructed inside the adapter
(`adapter.ts:1576`), so its scope is one adapter instance: one process, one
seat. Nothing counts seats. So `unsubscribe` reaches "nobody is listening"
on the strength of one seat's own narrows and issues
`DELETE /users/me/subscriptions` (`adapter.ts:1712-1730`) — where "me" is
the shared minter. One agent unsubscribing from a channel deafens every
other agent on it, until some unrelated seat's boot reconciler happens to
resubscribe.
was live. `streamIsListening` does refcount — but over the narrow *kinds* one
seat holds on a channel (`channel:X` against `new-topics:X`), within a single
`InboxState`. That state lives behind an `inboxRef` constructed inside the
adapter, so its scope is one adapter instance: one process, one seat. Nothing
counted seats. So `unsubscribe` reached "nobody is listening" on the strength
of one seat's own narrows and issued `DELETE /users/me/subscriptions` — where
"me" was the shared minter. One agent unsubscribing from a channel deafened
every other agent on it, until some unrelated seat's boot reconciler happened
to resubscribe.

That bug is now **deleted rather than fixed**, and the distinction is the
point: nothing counts seats today either. The queue and the subscriptions moved
to the seat's own principal, so "me" is the seat, and one seat's `DELETE`
cannot name another's row. The refcounting the architecture never built is not
owed — there is no shared subscription left to unwind. Queue and subscription
had to move together, because Zulip builds a channel message's recipient set
from the channel's subscription rows: a seat-owned queue over minter-held
subscriptions would have received nothing at all.

What has not moved yet: the minter still holds its blanket public-stream
subscription, and narrowing is still a client-side filter. Those are the next
steps of the same unwinding, not exemptions.

Principle 5 catches it at the first step. Principle 3 catches the store.
Principle 1 catches `session_id` reaching the tool surface.
Expand Down
28 changes: 28 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,34 @@ For a container, pin the published package version at image build time — e.g.
command `commy-mcp` (or `npx @codeforbreakfast/commy-mcp`), so boot resolves the
already-present bundle and never reaches the network.

## An ephemeral non-CC host must supply a session id to receive

**Behaviour change.** Receiving is state the realm holds on the agent's behalf,
so a seat's event queue and its subscriptions now live under **its own**
principal rather than the shared minter's. A seat therefore has to be able to
obtain an identity before it can subscribe or receive at all — and an ephemeral
identity is named from the session id (`cc-[<project>-]<first-8>`), so no
session id means no name to mint under, and no reception.

This affects one host class: an **ephemeral** host (no `COMMY_BOT_NAME`) that
injects no session id. Such a seat previously received channel traffic through
the minter's queue, since the minter was subscribed to every public stream and
listened on behalf of un-minted seats. That crutch is being retired, so the
seat's inability to identify itself is now visible instead of masked: its
boot-time subscribe is refused, it registers no queue, and it logs a warning
naming what it lost. It keeps serving; it does not receive.

Two supported ways to avoid it, both already documented above:

- Set `COMMY_BOT_NAME` for a persistent identity — the session id is irrelevant
in that mode, and the bot subscribes under its own stable principal.
- Pass a UUID `session_id` in the tool-call arguments, which is the binding an
ephemeral non-CC host has (`docs/claude-channel-inbound-contract.md`).

Claude Code seats are unaffected: the plugin injects `CLAUDE_CODE_SESSION_ID`
into the MCP child's environment at spawn, so the id is known before the seat's
first subscribe.

## Inbound is host work

A standalone MCP client on the open pipe physically receives inbound events (each
Expand Down
12 changes: 10 additions & 2 deletions packages/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,8 +663,16 @@ export interface MessageInbox {
*
* and trust that the post will be observable on the stream.
*/
subscribe(target: SubscriptionTarget): Effect.Effect<void, InboxError>
unsubscribe(target: SubscriptionTarget): Effect.Effect<void, InboxError>
/**
* Declaring interest writes realm state under the agent's own principal —
* a subscription row, and the event queue that delivers against it — so
* these carry {@link BindError} for the same reason the write verbs do.
* A seat that cannot bind is refused rather than silently falling back to
* a shared principal, which would leave it reading a surface that is not
* its own.
*/
subscribe(target: SubscriptionTarget): Effect.Effect<void, BindError | InboxError>
unsubscribe(target: SubscriptionTarget): Effect.Effect<void, BindError | InboxError>
/**
* Effect-native Stream of inbound events. Adapters drive this from
* their substrate's event mechanism (Zulip's events queue, Discord
Expand Down
16 changes: 10 additions & 6 deletions packages/mcp/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BotName, InboxError, MessageInbox } from '@commy/core/ports'
import type { BindError, BotName, InboxError, MessageInbox } from '@commy/core/ports'
import { decodeBotNameSync } from '@commy/core/ports'
import type { ZulipAdapter } from '@commy/zulip/adapter'
import { zulipAdapter } from '@commy/zulip/adapter'
Expand Down Expand Up @@ -720,16 +720,20 @@ export const ZulipAdapterLive: Layer.Layer<
* to tee only matching events to the MCP host.
* 2. `inbox.subscribe` keeps the substrate side wired so the
* adapter actually receives events. For Zulip this calls
* `/users/me/subscriptions` against the minter, ensuring the
* stream is in the minter's queue. The boot-time reconciler
* covers most streams; this per-session call still
* handles streams created after the plugin booted.
* `/users/me/subscriptions` under the SEAT's own principal and
* registers the seat's event queue, so the stream lands in a queue
* the seat owns rather than a shared one.
*
* Because that is realm state under the agent's own principal, this
* binds — hence the `BindError` in the error channel. A seat with no
* way to obtain an identity cannot hold subscriptions at all, and is
* refused here rather than silently seeded onto someone else's.
*/
export const subscribeFromEnv = (
inbox: MessageInbox,
narrowSet: NarrowSet,
parsed: ParsedEnv,
): Effect.Effect<ReadonlyArray<SubscribeIntent>, SubscribeTokenError | InboxError> => {
): Effect.Effect<ReadonlyArray<SubscribeIntent>, SubscribeTokenError | BindError | InboxError> => {
if (parsed.subscribe === undefined) return Effect.succeed([])
return Effect.forEach(parsed.subscribe.split(','), (raw) =>
parseSubscribeTarget(raw.trim()).pipe(
Expand Down
Loading