Skip to content

Commit 23ecb69

Browse files
committed
Subscribe to the project channel by name again
Default subscriptions no longer skip a project channel the realm lacks, so a new project's first seat creates its channel under the full name. The bot name alone stays capped at twelve characters.
1 parent 0a73422 commit 23ecb69

4 files changed

Lines changed: 32 additions & 141 deletions

File tree

‎clients/claude-code/README.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ process env inheritance:
6060
| Env var | Required | Format | Purpose |
6161
|---|---|---|---|
6262
| `COMMY_BOT_NAME` | no | `<role>` or `<rig>-<agent>` (see `docs/naming.md`) | **Persistent mode.** Stable identity to acquire eagerly at boot — concierges, scheduled skills, anything that needs to be DM-able from the moment the plugin starts. Boot fails non-zero on acquire rejection. Omit for ephemeral mode (next rows). |
63-
| `COMMY_PROJECT` | no | project slug (lowercase, `[a-z0-9-]` post-sanitise) | Identifies the calling project for two purposes: (1) **Ephemeral mode, operator override.** Force every minted name to embed this project tag — `cc-<project>-<8>` rather than the per-session value. When unset (the normal case under Claude Code), the project is derived **per attribution call** from the calling session's cwd (hook-injected): git remote origin basename → git root basename → `undefined` (bare `cc-<8>`). The env value, when set, wins over per-call derivation. (2) **Persistent mode (project concierge), Type-1 boot-time defaults.** Post-acquire the plugin registers `new-topics:<project>` + `<project>/general` so the concierge sees first-message-per-new-topic and project broadcast traffic. When unset, or when the realm has no channel by that name, the project-specific defaults are skipped rather than creating the channel, leaving no narrows registered; mentions of the bot still arrive, because they need no narrow. See `docs/naming.md` for the full precedence and sanitisation rules. |
63+
| `COMMY_PROJECT` | no | project slug (lowercase, `[a-z0-9-]` post-sanitise) | Identifies the calling project for two purposes: (1) **Ephemeral mode, operator override.** Force every minted name to embed this project tag — `cc-<project>-<8>` rather than the per-session value. When unset (the normal case under Claude Code), the project is derived **per attribution call** from the calling session's cwd (hook-injected): git remote origin basename → git root basename → `undefined` (bare `cc-<8>`). The env value, when set, wins over per-call derivation. (2) **Persistent mode (project concierge), Type-1 boot-time defaults.** Post-acquire the plugin registers `new-topics:<project>` + `<project>/general` so the concierge sees first-message-per-new-topic and project broadcast traffic. When unset the project-specific defaults are skipped, leaving no narrows registered; mentions of the bot still arrive, because they need no narrow. See `docs/naming.md` for the full precedence and sanitisation rules. |
6464

6565
### Eager vs lazy boot, in one diagram
6666

@@ -73,8 +73,7 @@ parseEnv → buildAdapter →
7373
│ post-acquire register Type-1 defaults:
7474
│ `new-topics:<project>` +
7575
│ `<project>/general` if
76-
│ COMMY_PROJECT is set and the realm
77-
│ has that channel (none if not —
76+
│ COMMY_PROJECT is set (none if not —
7877
│ mentions arrive without a narrow)
7978
│
8079
└── else → ephemeral 1-slot cache →
@@ -89,7 +88,7 @@ parseEnv → buildAdapter →
8988
the next attribution. Each fresh slot's
9089
onAcquire hook registers the Type-2 default:
9190
per-project `<project>/general`
92-
when known and the channel exists.
91+
when known.
9392
│
9493
▼
9594
subscribeFromEnv → registerTools → connect transport →

‎packages/mcp/server.integration.test.ts‎

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,33 +1936,6 @@ test('ephemeral mode + long project: the bot name is shortened but the default n
19361936
}
19371937
})
19381938

1939-
test('ephemeral mode + project with no channel on the realm: the default narrow is skipped, not created', async () => {
1940-
const cap = captureSubscribes()
1941-
const logs: string[] = []
1942-
const h = await buildHarness({
1943-
ephemeral: true,
1944-
env: { COMMY_PROJECT: 'example-long-project' },
1945-
seedChannels: ['home'],
1946-
inboxOverrides: cap.inboxOverrides,
1947-
capturedLogs: logs,
1948-
})
1949-
try {
1950-
await callTool(h.client, 'post', {
1951-
channel_name: 'home',
1952-
body: 'first attribution',
1953-
session_id: 'a1aaa1aa-0000-4000-8000-000000000008',
1954-
})
1955-
expect(cap.tokens).toEqual([])
1956-
expect(
1957-
logs.some((line) =>
1958-
line.includes('no channel named example-long-project exists on the realm'),
1959-
),
1960-
).toBe(true)
1961-
} finally {
1962-
await h.cleanup()
1963-
}
1964-
})
1965-
19661939
test('ephemeral mode + project: current_identity (passive read) does NOT register defaults', async () => {
19671940
const cap = captureSubscribes()
19681941
const h = await buildHarness({
@@ -2227,21 +2200,6 @@ test('persistent boot with a long COMMY_PROJECT registers Type-1 defaults on the
22272200
}
22282201
})
22292202

2230-
test('persistent boot whose project has no channel on the realm registers no Type-1 defaults', async () => {
2231-
const cap = captureSubscribes()
2232-
const h = await buildHarness({
2233-
env: { COMMY_PROJECT: 'myproject' },
2234-
seedChannels: ['home'],
2235-
inboxOverrides: cap.inboxOverrides,
2236-
})
2237-
try {
2238-
await new Promise((r) => setTimeout(r, 50))
2239-
expect(cap.tokens).toEqual([])
2240-
} finally {
2241-
await h.cleanup()
2242-
}
2243-
})
2244-
22452203
test('persistent boot Type-1 intents feed the channels catch-up (new-topics + thread/general history reads)', async () => {
22462204
const readChannelCalls: Array<{ channel: string }> = []
22472205
const readThreadCalls: Array<{ channel: string; thread: string }> = []

‎packages/mcp/server.test.ts‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,6 @@ const buildFakeAdapter = (
181181
readonly identityOrigin?: IdentityOrigin
182182
/** Reject every substrate-side subscribe, for the part-way-failure paths. */
183183
readonly subscribeError?: InboxError
184-
/** Channels the realm already has. */
185-
readonly channels?: ReadonlyArray<string>
186184
} = {},
187185
): { readonly adapter: ZulipAdapter; readonly calls: FakeAdapterCalls } => {
188186
const acquired: string[] = []
@@ -254,16 +252,7 @@ const buildFakeAdapter = (
254252
const directory: Directory = {
255253
listAgents: () => Effect.succeed([]),
256254
listHumans: () => Effect.succeed([]),
257-
listChannels: () =>
258-
Effect.succeed(
259-
(options.channels ?? []).map((name, index) => ({
260-
id: decodeChannelIdSync(String(index + 1)),
261-
name: decodeChannelNameSync(name),
262-
permalink: ChannelPermalinkSchema.make(
263-
`https://zulip.example.com/#narrow/channel/${name}`,
264-
),
265-
})),
266-
),
255+
listChannels: () => Effect.succeed([]),
267256
channelDescription: () => Effect.succeedNone,
268257
presence: (_id: Identity): Effect.Effect<Presence> => Effect.succeed('offline'),
269258
}
@@ -732,7 +721,7 @@ test('main applies env-driven subscriptions in order after acquire and Type-1 de
732721
// ─── Type-1 default sub set for project concierges ──────────────
733722

734723
test('persistent mode + project registers Type-1 defaults (new-topics + thread/general)', async () => {
735-
const fake = buildFakeAdapter({ channels: ['foo'] })
724+
const fake = buildFakeAdapter()
736725
const env = { ...validEnv, COMMY_PROJECT: 'foo' }
737726
await runProgram(env, fake.adapter)
738727
expect(fake.calls.acquired).toEqual(['myproject-concierge'])
@@ -757,7 +746,7 @@ test('persistent mode without project registers no narrows at all', async () =>
757746
})
758747

759748
test('Type-1 defaults register after acquire and before COMMY_SUBSCRIBE entries', async () => {
760-
const fake = buildFakeAdapter({ channels: ['foo'] })
749+
const fake = buildFakeAdapter()
761750
const env = {
762751
...validEnv,
763752
COMMY_PROJECT: 'foo',
@@ -809,7 +798,7 @@ test('Type-1 default failure is logged + continues — does not crash boot', asy
809798
// transient Zulip hiccup never refuses concierge boot. The bot is
810799
// already minted at this point — refusing service over a missing
811800
// default would be worse than the missing default.
812-
const fake = buildFakeAdapter({ channels: ['foo'] })
801+
const fake = buildFakeAdapter()
813802
let calls = 0
814803
const failingInbox: MessageInbox = {
815804
...fake.adapter.inbox,

‎packages/mcp/server.ts‎

Lines changed: 25 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ import type {
44
AcquiredIdentity,
55
AgentComms,
66
BindError,
7-
ChannelName,
8-
Directory,
9-
DirectoryError,
107
InboxError,
118
MessageInbox,
129
} from '@commy/core/ports'
@@ -15,18 +12,7 @@ import { CommandExecutor, FetchHttpClient, FileSystem, type HttpClient } from '@
1512
import { NodeContext, NodeRuntime } from '@effect/platform-node'
1613
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
1714
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
18-
import {
19-
Array as Arr,
20-
Cause,
21-
ConfigProvider,
22-
Data,
23-
Deferred,
24-
Effect,
25-
Layer,
26-
Option,
27-
type ParseResult,
28-
Predicate,
29-
} from 'effect'
15+
import { Cause, ConfigProvider, Data, Deferred, Effect, Layer, Option, Predicate } from 'effect'
3016
import type { BotName, GitContext, ParsedEnv, ProjectSlug, SessionId } from './bootstrap.ts'
3117
import {
3218
readGitContext as defaultReadGitContext,
@@ -132,38 +118,12 @@ export interface ProgramParams {
132118
/** Race budget for `identity.release()` in the shutdown finalizer. */
133119
const RELEASE_TIMEOUT_MS = 5000
134120

135-
/**
136-
* The project's channel, when the realm already has one. A default
137-
* subscription reads this rather than subscribing by name, because on Zulip
138-
* subscribing to a channel that does not exist creates it.
139-
*/
140-
const existingProjectChannel = (
141-
directory: Directory,
142-
project: ProjectSlug | undefined,
143-
): Effect.Effect<Option.Option<ChannelName>, DirectoryError | ParseResult.ParseError> =>
144-
project === undefined
145-
? Effect.succeedNone
146-
: Effect.all([decodeChannelName(project), directory.listChannels()]).pipe(
147-
Effect.map(([wanted, channels]) => Arr.findFirst(channels, (c) => c.name === wanted)),
148-
Effect.tap(
149-
Option.match({
150-
onNone: () =>
151-
Effect.logWarning(
152-
`commy plugin: skipping the project default subscriptions — no channel named ${project} exists on the realm`,
153-
),
154-
onSome: () => Effect.void,
155-
}),
156-
),
157-
Effect.map(Option.map((channel) => channel.name)),
158-
)
159-
160121
/**
161122
* Type-2 default sub set for interactive CC sessions. Fires
162123
* once per ephemeral slot, right after the substrate-side acquire
163124
* resolves: registers the project broadcast topic
164125
* `<project>/general` (skipped when no project slug could be
165-
* derived, or the realm has no channel by that name). Mentions need no
166-
* narrow — they arrive unconditionally.
126+
* derived). Mentions need no narrow — they arrive unconditionally.
167127
*
168128
* Failures are swallowed with a log line — the bot is already minted at
169129
* this point and refusing the caller's tool call over a transient
@@ -175,25 +135,20 @@ const existingProjectChannel = (
175135
const createType2DefaultsOnAcquire = (
176136
narrowSet: NarrowSet,
177137
inbox: MessageInbox,
178-
directory: Directory,
179138
): ((project: ProjectSlug | undefined) => Effect.Effect<void>) => {
180139
const registerIntent = (intent: SubscribeIntent): Effect.Effect<void, BindError | InboxError> =>
181140
Effect.sync(() => narrowSet.add(intent)).pipe(
182141
Effect.zipRight(inbox.subscribe(intentToTarget(intent))),
183142
)
184143
return (project) =>
185-
existingProjectChannel(directory, project).pipe(
186-
Effect.flatMap(
187-
Option.match({
188-
onNone: () => Effect.void,
189-
onSome: (channelName) =>
190-
decodeThreadName('general').pipe(
191-
Effect.flatMap((threadName) =>
192-
registerIntent({ kind: 'thread', channelName, threadName }),
193-
),
194-
),
195-
}),
196-
),
144+
(project !== undefined
145+
? Effect.all([decodeChannelName(project), decodeThreadName('general')]).pipe(
146+
Effect.flatMap(([channelName, threadName]) =>
147+
registerIntent({ kind: 'thread', channelName, threadName }),
148+
),
149+
)
150+
: Effect.void
151+
).pipe(
197152
Effect.catchAll((err) =>
198153
Effect.logError(
199154
`commy plugin: Type-2 default narrow registration failed: ${Cause.pretty(Cause.fail(err))}`,
@@ -212,8 +167,8 @@ const createType2DefaultsOnAcquire = (
212167
* enquiry surfaces while replies in unrelated topics stay quiet.
213168
* 2. `<project>/general` — project broadcast topic.
214169
*
215-
* Both are skipped when no project slug resolves from `COMMY_PROJECT`, or
216-
* the realm has no channel by that name, leaving the set empty — mentions still arrive, because they are
170+
* Both are skipped when no project slug resolves from `COMMY_PROJECT`,
171+
* leaving the set empty — mentions still arrive, because they are
217172
* unconditional and need no narrow. Failures swallow with a log
218173
* line — the bot is already minted at this point and refusing service
219174
* over a transient substrate hiccup would be worse than the missing
@@ -232,24 +187,19 @@ const logType1Failure = (err: unknown): Effect.Effect<void> =>
232187
const registerType1DefaultsOnBoot = (
233188
inbox: MessageInbox,
234189
narrowSet: NarrowSet,
235-
directory: Directory,
236190
project: ProjectSlug | undefined,
237191
): Effect.Effect<ReadonlyArray<SubscribeIntent>> =>
238-
existingProjectChannel(directory, project).pipe(
239-
Effect.flatMap(
240-
Option.match({
241-
onNone: () => Effect.succeed<ReadonlyArray<SubscribeIntent>>([]),
242-
onSome: (channelName) =>
243-
decodeThreadName('general').pipe(
244-
Effect.map(
245-
(threadName): ReadonlyArray<SubscribeIntent> => [
246-
{ kind: 'new-topics-in-channel', channelName },
247-
{ kind: 'thread', channelName, threadName },
248-
],
249-
),
250-
),
251-
}),
252-
),
192+
(project !== undefined
193+
? Effect.all([decodeChannelName(project), decodeThreadName('general')]).pipe(
194+
Effect.map(
195+
([channelName, threadName]): ReadonlyArray<SubscribeIntent> => [
196+
{ kind: 'new-topics-in-channel', channelName },
197+
{ kind: 'thread', channelName, threadName },
198+
],
199+
),
200+
)
201+
: Effect.succeed<ReadonlyArray<SubscribeIntent>>([])
202+
).pipe(
253203
Effect.flatMap((intents) =>
254204
Effect.forEach(intents, (intent) =>
255205
Effect.sync(() => narrowSet.add(intent)).pipe(
@@ -613,7 +563,7 @@ export const makeProgram = (
613563
// persistent mode (which registers Type-1 defaults at boot instead).
614564
const registerType2Defaults =
615565
parsed.botName === undefined
616-
? createType2DefaultsOnAcquire(narrowSet, adapter.inbox, adapter.directory)
566+
? createType2DefaultsOnAcquire(narrowSet, adapter.inbox)
617567
: undefined
618568

619569
// Rebuild this seat's narrow set from the realm, narrowed by whatever
@@ -852,12 +802,7 @@ export const makeProgram = (
852802
persistentIdentity = acquired
853803
}).pipe(
854804
Effect.zipRight(
855-
registerType1DefaultsOnBoot(
856-
adapter.inbox,
857-
narrowSet,
858-
adapter.directory,
859-
parsed.project,
860-
),
805+
registerType1DefaultsOnBoot(adapter.inbox, narrowSet, parsed.project),
861806
),
862807
),
863808
}),

0 commit comments

Comments
 (0)