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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ minter user that owns your agents' bot identities (see
| `ZULIP_MINTER_EMAIL` | yes | Email of the minter user that owns every agent bot. Must be in the realm's `can_create_bots_group`. |
| `ZULIP_MINTER_API_KEY` | yes | The minter's API key. Stored in the system keychain — never in `settings.json`. |

There's also an optional `COMMY_SUBSCRIBE` (comma-separated auto-subscribe
tokens, e.g. `my-project`) for agents that should already be
listening the moment they boot. Mentions of the bot need no token — they
There's also an optional `COMMY_SUBSCRIBE` (comma-separated subscribe tokens,
e.g. `my-project`) for agents that should already be listening the moment they
boot. It bootstraps a bot's subscriptions when the bot is created and is not
read again — after that the agent owns them and changes them with the
`subscribe` / `unsubscribe` tools. Mentions of the bot need no token — they
always arrive. To set any of these non-interactively, repeat
`--config KEY=value` on the `install` line.

Expand Down
2 changes: 1 addition & 1 deletion clients/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ the env block in `.mcp.json`.
| `ZULIP_SITE` | yes | no | absolute URL, e.g. `https://zulip.example.com` | Base of the Zulip realm the plugin operates against. |
| `ZULIP_MINTER_EMAIL` | yes | no | email-shaped string | Delivery email of the shared minter user (human-type Zulip user) that owns all bots managed through this plugin. Must be a member of the realm's `can_create_bots_group`. |
| `ZULIP_MINTER_API_KEY` | yes | yes | opaque token | Minter user's API key. Used to mint or regenerate bot credentials at boot. |
| `COMMY_SUBSCRIBE` | no | no | comma-separated tokens: `<channel>`, `<channel>/<thread>`, `new-topics:<channel>` | Pre-loaded inbox subscriptions. A bare path is scope, a prefix is a delivery rule — `new-topics:` is the only prefix left. Mentions of the bot need no token — they arrive unconditionally; the retired `channel:` / `thread:` prefixes and the retired `mentions` token are rejected as config errors. Applied at MCP-child boot before tools are announced, in both eager and lazy modes — the minter is the universal listener; this just teaches the plugin which events to surface. Two supply paths, and they compose: this prompted value is written to `COMMY_SUBSCRIBE_USER_CONFIG`, and the plugin falls back to an inherited `COMMY_SUBSCRIBE` when that is empty. The plugin therefore never overwrites a value you set by other means — a systemd unit, a pane env, a nix module — which it did while it wrote the substitution to the bare name. Leaving both unset is not a boot failure; it means no subscriptions. |
| `COMMY_SUBSCRIBE` | no | no | comma-separated tokens: `<channel>`, `<channel>/<thread>`, `new-topics:<channel>` | Pre-loaded inbox subscriptions. A bare path is scope, a prefix is a delivery rule — `new-topics:` is the only prefix left. Mentions of the bot need no token — they arrive unconditionally; the retired `channel:` / `thread:` prefixes and the retired `mentions` token are rejected as config errors. Applied ONCE, when the bot is created, in both eager and lazy modes; after that the bot owns its subscriptions and changes them through the `subscribe` / `unsubscribe` tools, so editing this for a bot that already exists has no effect. Two supply paths, and they compose: this prompted value is written to `COMMY_SUBSCRIBE_USER_CONFIG`, and the plugin falls back to an inherited `COMMY_SUBSCRIBE` when that is empty. The plugin therefore never overwrites a value you set by other means — a systemd unit, a pane env, a nix module — which it did while it wrote the substitution to the bare name. Leaving both unset is not a boot failure; it means no subscriptions. |

Paste each value into the prompt when it appears on first enable. To re-enter
values later, edit `pluginConfigs[...].options` in `~/.claude/settings.json`
Expand Down
10 changes: 9 additions & 1 deletion clients/claude-code/hooks-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const BOUND_HTTP_CALLERS = [
'setChannelDescription',
'setThreadResolved',
'subscribe',
'subscriptions',
'unreact',
'unsubscribe',
] as const
Expand All @@ -64,13 +65,20 @@ const BOUND_HTTP_CALLERS = [
* a subscription row and the event queue that delivers against it — so these
* bind for the same reason the publisher verbs do.
*
* `subscriptions` (comms-g5zh.5) is the one READ in this set, and it belongs
* here for a different reason than the writes: it asks what the realm holds
* FOR THIS SEAT, so it can only be answered by the seat's own credential. The
* minter's answer would be a different seat's subscriptions wearing this one's
* name. It never causes a mint, though — its only caller checks that the seat
* is already bound, or has grounds to believe its bot exists, before asking.
*
* 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
const BOUND_INBOX_VERBS = ['subscribe', 'subscriptions', 'unsubscribe'] as const

/**
* Tools that reach `boundHttp` through an inbox verb while sitting outside the
Expand Down
26 changes: 18 additions & 8 deletions docs/agent-experience.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,14 @@ Places the current implementation fails this reference.
exactly one test-side module, this one" — and production code has since
eroded it. (`bootstrap.ts` naming the adapter is fine; that is the
composition root choosing an implementation.) Principle 2.
- **Sticky subscriptions are keyed on `session_id`, not identity**
(`packages/mcp/subscription-store.ts`). Subscriptions belong to the
account: a human relaunching their browser keeps them. The code
deliberately rejected identity-keying to avoid treating a relaunch of a
pinned pane as a resume — but under this reference, that is the correct
behaviour, not a bug to avoid. Principle 3.
- **What survives a restart is keyed on `session_id`, not identity**
(`packages/mcp/subscription-store.ts`). Mostly closed: a seat now reads its
channel subscriptions back from the realm under its own principal, so a
relaunched pinned pane keeps them the way a human relaunching a browser
does. What is still session-keyed is the record of TOPIC-level narrows —
the part a subscription row cannot express. That record is the last piece
of client-side authority here, and the paragraph on topic narrows below
says where it belongs instead. Principle 3.
- **Exploration is thinner than a human's.** Agents get `read_channel`,
`read_thread` and `list_channels`; a human member also gets search and
unread state. Principle 1, prospectively — this is a gap to fill, not
Expand Down Expand Up @@ -160,6 +162,12 @@ deafen another. A filter that lives in memory is lost on resume, so it needs
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 store step is now mostly undone too. Once subscriptions are the seat's own
realm state, resume stops being "restore what I saved" and becomes "ask what I
am subscribed to" — what a human's client does. Only the topic-level narrows
still need a local record, because a subscription row names a channel and
nothing finer.

The refcounting step is the one the architecture never took, and its absence
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
Expand All @@ -181,8 +189,10 @@ 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.
subscription, and topic-level narrowing is still a client-side filter with a
local record behind it. Those are the next steps of the same unwinding, not
exemptions — channel-level narrowing has moved, and is now read back from the
realm on every boot.

Principle 5 catches it at the first step. Principle 3 catches the store.
Principle 1 catches `session_id` reaching the tool surface.
Expand Down
37 changes: 33 additions & 4 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ no-op-ish values when unset):
|---|---|---|
| `COMMY_BOT_NAME` | no | Persistent mode: a stable identity acquired eagerly at boot (for concierges / scheduled agents). Omit for ephemeral, per-session identities. |
| `COMMY_PROJECT` | no | Project slug used for channel naming and a persistent agent's project subscriptions. When unset it is derived per-session from the calling cwd (git remote / git root). |
| `COMMY_SUBSCRIBE` | no | Comma-separated auto-subscribe tokens applied at boot: `<channel>` for a whole channel, `<channel>/<thread>` for one topic in it, `new-topics:<channel>` for the first message of each new topic. Blank means no auto-subscription — the bot still receives its own mentions, which are implicit and need no token. The retired `channel:` / `thread:` prefixes and the retired `mentions` token are rejected as config errors. Under the Claude Code plugin this may also arrive as `COMMY_SUBSCRIBE_USER_CONFIG`, which takes precedence; see [Two supply paths for the optional `COMMY_*` values](#two-supply-paths-for-the-optional-commy-values). |
| `COMMY_SUBSCRIBE` | no | Comma-separated tokens that **bootstrap a new bot's subscriptions, once**: `<channel>` for a whole channel, `<channel>/<thread>` for one topic in it, `new-topics:<channel>` for the first message of each new topic. Applied when the bot is created and never again — see [`COMMY_SUBSCRIBE` bootstraps a bot; it does not configure one](#commy_subscribe-bootstraps-a-bot-it-does-not-configure-one). Blank means no bootstrap — the bot still receives its own mentions, which are implicit and need no token. The retired `channel:` / `thread:` prefixes and the retired `mentions` token are rejected as config errors. Under the Claude Code plugin this may also arrive as `COMMY_SUBSCRIBE_USER_CONFIG`, which takes precedence; see [Two supply paths for the optional `COMMY_*` values](#two-supply-paths-for-the-optional-commy-values). |
| `COMMY_CATCHUP_WINDOW_SECONDS` | no | How far back to fetch recent messages across the boot-time subscribe set on a persistent restart. Default `14400` (4 hours); `0` disables. |
| `COMMY_QUEUE_IDLE_TIMEOUT_SECS` | no | How many seconds an ephemeral session's events queue survives without a poll before Zulip garbage-collects it, sent as `idle_queue_timeout` on `/register`. Default `86400` (24 hours); clamped to Zulip's 7-day `MAX_QUEUE_TIMEOUT_SECS` ceiling (`604800`). A non-positive or non-integer value fails boot with a config error. |
| `COMMY_DOWNLOAD_DIR` | no | Base directory for `download_file` attachments. When set, each download's fresh temp subdirectory is created under it so files land somewhere an allowlisted agent can `Read`; when unset, downloads go to `$TMPDIR`. Must be an existing directory — a non-directory value fails boot with a config error. |
Expand Down Expand Up @@ -69,14 +69,43 @@ booted a seat that was silently deaf to every channel it was meant to watch.
Giving the manifest its own key space makes that impossible: the plugin can only
ever clobber a name it alone owns.

If you configure subscriptions and want to confirm they arrived, boot logs a
line naming the applied tokens in the same vocabulary you wrote them:
If you configure subscriptions and want to confirm they arrived, the boot that
creates the bot logs a line naming the applied tokens in the same vocabulary you
wrote them:

```
commy plugin: applied 2 boot-time subscribe target(s): myproject, general/standup
```

No such line means no boot-time subscriptions were applied.
That line appears **once in a bot's life**. On a later launch you get this
instead, which is the design working rather than a fault:

```
commy plugin: COMMY_SUBSCRIBE not applied — myproject-concierge already exists
and owns its subscriptions.
```

## `COMMY_SUBSCRIBE` bootstraps a bot; it does not configure one

`COMMY_SUBSCRIBE` is applied at the moment a bot is created, and never read
again. From then on the bot owns its subscriptions: they live in the realm under
its own principal, and they change when the agent itself calls `subscribe` or
`unsubscribe`.

**Editing `COMMY_SUBSCRIBE` for a bot that already exists has no effect.** To
change what a running agent listens to, ask the agent — it has the tools. To
start over from the launcher, delete the bot; the next boot creates a new one and
bootstraps it from the current value.

The reason is that a launcher value and an agent's own calls cannot both be
authoritative. When both were, the same `COMMY_SUBSCRIBE` read at two launches of
one bot was indistinguishable from the bot's own runtime changes, and the two
quietly fought. The realm holds one answer now, and the agent is the one who
changes it.

One consequence for an upgrade: a bot that already existed before this behaviour
landed is **not** bootstrapped retroactively. It keeps whatever the realm holds
for it — and a bot that finds itself with nothing can subscribe to what it wants.

## Realm settings that shape commy behaviour

Expand Down
36 changes: 36 additions & 0 deletions packages/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,9 +445,28 @@ export interface RealmSettings {
*/
export type Credentials = Readonly<Record<string, string>>

/**
* Whether an acquire brought an identity into existence or bound to one that
* was already there.
*
* The substrate answers this as a by-product of acquiring — Zulip's acquire is
* a lookup that mints on miss — so a caller reads the answer rather than
* inferring one from local state. That matters because the alternative
* inferences are all wrong in a way that shows up only in production: "no
* subscriptions yet" cannot tell a never-seeded bot from one that deliberately
* unsubscribed from everything, and "no local state file" cannot tell a fresh
* seat from one whose state was pruned.
*
* `minted` is the one moment a seat first exists, and it is what bootstrap
* hangs off: `COMMY_SUBSCRIBE` seeds a seat's subscriptions there and nowhere
* else, because from that point the bot owns them.
*/
export type IdentityOrigin = 'minted' | 'existing'

export interface AcquiredIdentity {
readonly credentials: Credentials
readonly identity: Identity
readonly origin: IdentityOrigin
}

/**
Expand Down Expand Up @@ -673,6 +692,23 @@ export interface MessageInbox {
*/
subscribe(target: SubscriptionTarget): Effect.Effect<void, BindError | InboxError>
unsubscribe(target: SubscriptionTarget): Effect.Effect<void, BindError | InboxError>
/**
* The channels the realm currently delivers to this seat, read back from the
* subscription rows written under its own principal.
*
* This is what makes a seat's subscriptions survive a restart without the
* plugin keeping its own copy of them: on the way up, a seat asks the realm
* what it is subscribed to rather than replaying what it once asked for.
* Reading it binds — the answer is per-member state, so there is no answer
* before the seat has a principal, and an unbound seat's honest answer is not
* "nothing" but "there is no seat yet".
*
* CHANNELS ONLY, and that ceiling is the substrate's rather than ours: a
* subscription row names a channel, so a seat that only wants one topic in a
* channel and a seat that wants the whole channel are indistinguishable here.
* Narrowing below a channel stays with the caller.
*/
subscriptions(): Effect.Effect<ReadonlyArray<ChannelName>, BindError | InboxError>
/**
* Effect-native Stream of inbound events. Adapters drive this from
* their substrate's event mechanism (Zulip's events queue, Discord
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,7 @@ const buildFakeInbox = (options: { readonly rejectOn?: number } = {}): FakeInbox
return Effect.void
}),
unsubscribe: () => Effect.void,
subscriptions: () => Effect.succeed([]),
settingsChanges: () => Stream.empty,
events: () => Stream.empty,
replay: () => Effect.succeed([]),
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/deployed-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import type { BindOnDemand } from './session-binder.ts'
import { bindThrough, SessionBinder as SessionBinderTag } from './session-binder.ts'
import { SessionId as SessionIdTag, type SessionIdValue } from './session-id.ts'
import { SubscriptionStoreTag } from './subscription-store.ts'
import { testPlatformLayer } from './test-platform.ts'
import { testBootStoresLayer, testPlatformLayer } from './test-platform.ts'

const REPO_ROOT = join(import.meta.dir, '..', '..')
const PLUGIN_ROOT = join(REPO_ROOT, 'clients', 'claude-code')
Expand Down Expand Up @@ -238,6 +238,7 @@ const bootDeployedSeat = async (
Layer.succeed(SessionIdTag, sessionIdDeferred),
Layer.succeed(ResumeOutcomeTag, resumeOutcome),
Layer.succeed(SessionBinderTag, binderRef),
testBootStoresLayer(),
captureLogger([]),
),
testPlatformLayer(CLAUDE_CODE_CHILD_ENV),
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/disconnect-exit.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { clientDisconnect, makeProgram } from './server.ts'
import { SessionBinderLive } from './session-binder.ts'
import { SessionIdLive } from './session-id.ts'
import { SubscriptionStoreTag } from './subscription-store.ts'
import { testBootStoresLayer } from './test-platform.ts'

const inMemoryCursorStore = {
read: () => Effect.succeed(Option.none()),
Expand Down Expand Up @@ -80,6 +81,7 @@ NodeRuntime.runMain(
substrateAdapterLayer(substrate),
Layer.succeed(CursorStoreTag, inMemoryCursorStore),
Layer.succeed(SubscriptionStoreTag, inMemorySubscriptionStore),
testBootStoresLayer(),
SessionIdLive,
SessionBinderLive,
ResumeOutcomeLive,
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/ensure-bound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const buildIdentity = (name: string): Identity => ({
const buildAcquired = (name: string): AcquiredIdentity => ({
identity: buildIdentity(name),
credentials: { apiKey: `key-${name}` },
origin: 'minted',
})

const run = <A, E>(self: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(self)
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/event-pump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ const queueInbox = (options: QueueInboxOptions = { events: [] }): QueueInboxHand
const inbox: MessageInbox = {
subscribe: () => Effect.void,
unsubscribe: () => Effect.void,
subscriptions: () => Effect.succeed([]),
settingsChanges: () => Stream.empty,
events: () =>
Stream.async<InboundEvent>((emit) => {
Expand Down Expand Up @@ -200,6 +201,7 @@ test('pump reports realm settings changes without notifying the consumer', () =>
const inbox: MessageInbox = {
subscribe: () => Effect.void,
unsubscribe: () => Effect.void,
subscriptions: () => Effect.succeed([]),
settingsChanges: () => Stream.make({ editingAvailable: false }),
events: () => Stream.empty,
replay: () => Effect.succeed([]),
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/identity-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const buildIdentity = (name: string): Identity => ({
const buildAcquired = (name: string): AcquiredIdentity => ({
identity: buildIdentity(name),
credentials: { apiKey: `key-${name}` },
origin: 'minted',
})

/**
Expand Down
Loading