// Conditionally require()'d in LogoV2.tsx behind feature('KAIROS') ||
// feature('KAIROS_CHANNELS'). No feature() guard here — the whole file
// tree-shakes via the require pattern when both flags are false (see
// docs/feature-gating.md). Do NOT import this module statically from
// unguarded code.
import * as React from 'react'
import { useState } from 'react'
import {
type ChannelEntry,
getAllowedChannels,
getHasDevChannels,
} from '../../bootstrap/state.js'
import { Box, Text } from '../../ink.js'
import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js'
import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js'
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
import {
getClaudeAIOAuthTokens,
getSubscriptionType,
} from '../../utils/auth.js'
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
import { getSettingsForSource } from '../../utils/settings/settings.js'
export function ChannelsNotice(): React.ReactNode {
// Snapshot all reads at mount. This notice enters scrollback immediately
// after the logo; any re-render past that point forces a full terminal
// reset. getAllowedChannels (bootstrap state), getSettingsForSource
// (session cache updated by background polling / /login), and
// isChannelsEnabled (GrowthBook 5-min refresh) must be captured once
// so a later re-render cannot flip branches.
const [{ channels, disabled, noAuth, policyBlocked, list, unmatched }] =
useState(() => {
const ch = getAllowedChannels()
if (ch.length === 0)
return {
channels: ch,
disabled: false,
noAuth: false,
policyBlocked: false,
list: '',
unmatched: [] as Unmatched[],
}
const l = ch.map(formatEntry).join(', ')
const sub = getSubscriptionType()
const managed = sub === 'team' || sub === 'enterprise'
const policy = getSettingsForSource('policySettings')
const allowlist = getEffectiveChannelAllowlist(
sub,
policy?.allowedChannelPlugins,
)
return {
channels: ch,
disabled: !isChannelsEnabled(),
noAuth: !getClaudeAIOAuthTokens()?.accessToken,
policyBlocked: managed && policy?.channelsEnabled !== true,
list: l,
unmatched: findUnmatched(ch, allowlist),
}
})
if (channels.length === 0) return null
// When both flags are passed, the list mixes entries and a single flag
// name would be wrong for half of it. entry.dev distinguishes origin.
const hasNonDev = channels.some(c => !c.dev)
const flag =
getHasDevChannels() && hasNonDev
? 'Channels'
: getHasDevChannels()
? '--dangerously-load-development-channels'
: '--channels'
if (disabled) {
return (
{flag} ignored ({list})
Channels are not currently available
)
}
if (noAuth) {
return (
{flag} ignored ({list})
Channels require claude.ai authentication · run /login, then restart
)
}
if (policyBlocked) {
return (
{flag} blocked by org policy ({list})
Inbound messages will be silently dropped
Have an administrator set channelsEnabled: true in managed settings to
enable
{unmatched.map(u => (
{formatEntry(u.entry)} · {u.why}
))}
)
}
// "Listening for" not "active" — at this point we only know the allowlist
// was set. Server connection, capability declaration, and whether the name
// even matches a configured MCP server are all still unknown.
return (
Listening for channel messages from: {list}
Experimental · inbound messages will be pushed into this session, this
carries prompt injection risks. Restart Claude Code without {flag} to
disable.
{unmatched.map(u => (
{formatEntry(u.entry)} · {u.why}
))}
)
}
function formatEntry(c: ChannelEntry): string {
return c.kind === 'plugin'
? `plugin:${c.name}@${c.marketplace}`
: `server:${c.name}`
}
type Unmatched = { entry: ChannelEntry; why: string }
function findUnmatched(
entries: readonly ChannelEntry[],
allowlist: ReturnType,
): Unmatched[] {
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
// is not cached (project scope walks the dir tree); getMcpConfigByName would
// redo that walk per entry.
const scopes = ['enterprise', 'user', 'project', 'local'] as const
const configured = new Set()
for (const scope of scopes) {
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
configured.add(name)
}
}
// Plugin-kind installed check: installed_plugins.json keys are
// `name@marketplace`. loadInstalledPluginsV2 is cached.
const installedPluginIds = new Set(
Object.keys(loadInstalledPluginsV2().plugins),
)
// Plugin-kind allowlist check: same {marketplace, plugin} test as the
// gate at channelNotification.ts. entry.dev bypasses (dev flag opts out
// of the allowlist). Org list replaces ledger when set (team/enterprise).
// GrowthBook _CACHED_MAY_BE_STALE — cold cache yields [] so every plugin
// entry warns; same tradeoff the gate already accepts.
const { entries: allowed, source } = allowlist
// Independent ifs — a plugin entry that's both uninstalled AND
// unlisted shows two lines. Server kind checks config + dev flag.
const out: Unmatched[] = []
for (const entry of entries) {
if (entry.kind === 'server') {
if (!configured.has(entry.name)) {
out.push({ entry, why: 'no MCP server configured with that name' })
}
if (!entry.dev) {
out.push({
entry,
why: 'server: entries need --dangerously-load-development-channels',
})
}
continue
}
if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) {
out.push({ entry, why: 'plugin not installed' })
}
if (
!entry.dev &&
!allowed.some(
e => e.plugin === entry.name && e.marketplace === entry.marketplace,
)
) {
out.push({
entry,
why:
source === 'org'
? "not on your org's approved channels list"
: 'not on the approved channels allowlist',
})
}
}
return out
}