mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 恢复 --channels 能力 (#297)
* feat: 恢复 --channels 能力 * docs: 添加 channels 注释
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||||
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
||||||
|
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord 等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
|
||||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
||||||
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||||
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||||
|
|||||||
52
docs/features/channels.md
Normal file
52
docs/features/channels.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Channels — 外部频道消息接入
|
||||||
|
|
||||||
|
> 启动参数:`--channels` / `--dangerously-load-development-channels`
|
||||||
|
> 状态:已解除 feature flag 和 OAuth 限制,可直接使用
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Channel 是一个 MCP 服务器,它将外部事件推送到你运行中的 Claude Code 会话中,以便 Claude 可以在你不在终端时做出反应。详细使用说明请参考以下文档:
|
||||||
|
|
||||||
|
- **官方文档**:[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
|
||||||
|
- **飞书插件**:[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启用频道监听(plugin 格式)
|
||||||
|
ccb --channels plugin:feishu@claude-code-feishu-channel
|
||||||
|
|
||||||
|
# 启用频道监听(server 格式)
|
||||||
|
ccb --channels server:my-slack-bridge
|
||||||
|
|
||||||
|
# 同时启用多个频道
|
||||||
|
ccb --channels plugin:feishu@claude-code-feishu-channel --channels server:discord-bot
|
||||||
|
|
||||||
|
# 开发模式(跳过 allowlist 检查,用于测试自定义 channel)
|
||||||
|
ccb --dangerously-load-development-channels server:my-custom-channel
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的 Channel
|
||||||
|
|
||||||
|
| Channel | 说明 | 来源 |
|
||||||
|
|---------|------|------|
|
||||||
|
| **Telegram** | 官方 Telegram Bot 集成 | `/plugin install telegram@claude-plugins-official` |
|
||||||
|
| **Discord** | 官方 Discord Bot 集成 | `/plugin install discord@claude-plugins-official` |
|
||||||
|
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
|
||||||
|
| **飞书 (Feishu/Lark)** | 双向消息、群组聊天、文件附件 | `/plugin install feishu@claude-code-feishu-channel` |
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/services/mcp/channelNotification.ts` | 频道 gate 逻辑、消息包装 |
|
||||||
|
| `src/services/mcp/channelAllowlist.ts` | 频道开关(已默认开启) |
|
||||||
|
| `src/services/mcp/useManageMCPConnections.ts` | MCP 连接管理中的频道注册 |
|
||||||
|
| `src/components/LogoV2/ChannelsNotice.tsx` | 启动时频道状态提示 |
|
||||||
|
| `src/main.tsx` | `--channels` 参数解析 |
|
||||||
|
| `src/interactiveHelpers.tsx` | Dev channels 确认对话框 |
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [官方 Channels 文档](https://code.claude.com/docs/zh-CN/channels) — 完整使用说明、安全性、Enterprise 控制
|
||||||
|
- [飞书 Channel 插件](https://github.com/whobot-ai/claude-code-feishu-channel) — 安装配置教程、MCP 工具、Skill 命令参考
|
||||||
@@ -135,6 +135,7 @@
|
|||||||
"group": "运行模式",
|
"group": "运行模式",
|
||||||
"pages": [
|
"pages": [
|
||||||
"docs/features/kairos",
|
"docs/features/kairos",
|
||||||
|
"docs/features/channels",
|
||||||
"docs/features/voice-mode",
|
"docs/features/voice-mode",
|
||||||
"docs/features/bridge-mode",
|
"docs/features/bridge-mode",
|
||||||
"docs/features/remote-control-self-hosting",
|
"docs/features/remote-control-self-hosting",
|
||||||
|
|||||||
@@ -4948,7 +4948,7 @@ function handleChannelEnable(
|
|||||||
function reregisterChannelHandlerAfterReconnect(
|
function reregisterChannelHandlerAfterReconnect(
|
||||||
connection: MCPServerConnection,
|
connection: MCPServerConnection,
|
||||||
): void {
|
): void {
|
||||||
if (!(feature('KAIROS') || feature('KAIROS_CHANNELS'))) return
|
// Channels always available — feature flag guard removed
|
||||||
if (connection.type !== 'connected') return
|
if (connection.type !== 'connected') return
|
||||||
|
|
||||||
const gate = gateChannelServer(
|
const gate = gateChannelServer(
|
||||||
|
|||||||
@@ -12,50 +12,27 @@ import {
|
|||||||
getHasDevChannels,
|
getHasDevChannels,
|
||||||
} from '../../bootstrap/state.js'
|
} from '../../bootstrap/state.js'
|
||||||
import { Box, Text } from '@anthropic/ink'
|
import { Box, Text } from '@anthropic/ink'
|
||||||
import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js'
|
|
||||||
import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js'
|
|
||||||
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
||||||
import {
|
|
||||||
getClaudeAIOAuthTokens,
|
|
||||||
getSubscriptionType,
|
|
||||||
} from '../../utils/auth.js'
|
|
||||||
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
|
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
|
||||||
import { getSettingsForSource } from '../../utils/settings/settings.js'
|
|
||||||
|
|
||||||
export function ChannelsNotice(): React.ReactNode {
|
export function ChannelsNotice(): React.ReactNode {
|
||||||
// Snapshot all reads at mount. This notice enters scrollback immediately
|
// Snapshot all reads at mount. This notice enters scrollback immediately
|
||||||
// after the logo; any re-render past that point forces a full terminal
|
// after the logo; any re-render past that point forces a full terminal
|
||||||
// reset. getAllowedChannels (bootstrap state), getSettingsForSource
|
// reset.
|
||||||
// (session cache updated by background polling / /login), and
|
const [{ channels, list, unmatched }] =
|
||||||
// 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(() => {
|
useState(() => {
|
||||||
const ch = getAllowedChannels()
|
const ch = getAllowedChannels()
|
||||||
if (ch.length === 0)
|
if (ch.length === 0)
|
||||||
return {
|
return {
|
||||||
channels: ch,
|
channels: ch,
|
||||||
disabled: false,
|
|
||||||
noAuth: false,
|
|
||||||
policyBlocked: false,
|
|
||||||
list: '',
|
list: '',
|
||||||
unmatched: [] as Unmatched[],
|
unmatched: [] as Unmatched[],
|
||||||
}
|
}
|
||||||
const l = ch.map(formatEntry).join(', ')
|
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 {
|
return {
|
||||||
channels: ch,
|
channels: ch,
|
||||||
disabled: !isChannelsEnabled(),
|
|
||||||
noAuth: !getClaudeAIOAuthTokens()?.accessToken,
|
|
||||||
policyBlocked: managed && policy?.channelsEnabled !== true,
|
|
||||||
list: l,
|
list: l,
|
||||||
unmatched: findUnmatched(ch, allowlist),
|
unmatched: findUnmatched(ch),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (channels.length === 0) return null
|
if (channels.length === 0) return null
|
||||||
@@ -70,50 +47,6 @@ export function ChannelsNotice(): React.ReactNode {
|
|||||||
? '--dangerously-load-development-channels'
|
? '--dangerously-load-development-channels'
|
||||||
: '--channels'
|
: '--channels'
|
||||||
|
|
||||||
if (disabled) {
|
|
||||||
return (
|
|
||||||
<Box paddingLeft={2} flexDirection="column">
|
|
||||||
<Text color="error">
|
|
||||||
{flag} ignored ({list})
|
|
||||||
</Text>
|
|
||||||
<Text dimColor>Channels are not currently available</Text>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (noAuth) {
|
|
||||||
return (
|
|
||||||
<Box paddingLeft={2} flexDirection="column">
|
|
||||||
<Text color="error">
|
|
||||||
{flag} ignored ({list})
|
|
||||||
</Text>
|
|
||||||
<Text dimColor>
|
|
||||||
Channels require claude.ai authentication · run /login, then restart
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (policyBlocked) {
|
|
||||||
return (
|
|
||||||
<Box paddingLeft={2} flexDirection="column">
|
|
||||||
<Text color="error">
|
|
||||||
{flag} blocked by org policy ({list})
|
|
||||||
</Text>
|
|
||||||
<Text dimColor>Inbound messages will be silently dropped</Text>
|
|
||||||
<Text dimColor>
|
|
||||||
Have an administrator set channelsEnabled: true in managed settings to
|
|
||||||
enable
|
|
||||||
</Text>
|
|
||||||
{unmatched.map(u => (
|
|
||||||
<Text key={`${formatEntry(u.entry)}:${u.why}`} color="warning">
|
|
||||||
{formatEntry(u.entry)} · {u.why}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Listening for" not "active" — at this point we only know the allowlist
|
// "Listening for" not "active" — at this point we only know the allowlist
|
||||||
// was set. Server connection, capability declaration, and whether the name
|
// was set. Server connection, capability declaration, and whether the name
|
||||||
// even matches a configured MCP server are all still unknown.
|
// even matches a configured MCP server are all still unknown.
|
||||||
@@ -144,7 +77,6 @@ type Unmatched = { entry: ChannelEntry; why: string }
|
|||||||
|
|
||||||
function findUnmatched(
|
function findUnmatched(
|
||||||
entries: readonly ChannelEntry[],
|
entries: readonly ChannelEntry[],
|
||||||
allowlist: ReturnType<typeof getEffectiveChannelAllowlist>,
|
|
||||||
): Unmatched[] {
|
): Unmatched[] {
|
||||||
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
|
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
|
||||||
// is not cached (project scope walks the dir tree); getMcpConfigByName would
|
// is not cached (project scope walks the dir tree); getMcpConfigByName would
|
||||||
@@ -163,46 +95,17 @@ function findUnmatched(
|
|||||||
Object.keys(loadInstalledPluginsV2().plugins),
|
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[] = []
|
const out: Unmatched[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.kind === 'server') {
|
if (entry.kind === 'server') {
|
||||||
if (!configured.has(entry.name)) {
|
if (!configured.has(entry.name)) {
|
||||||
out.push({ entry, why: 'no MCP server configured with that 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
|
continue
|
||||||
}
|
}
|
||||||
if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) {
|
if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) {
|
||||||
out.push({ entry, why: 'plugin not installed' })
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,59 +332,25 @@ export async function showSetupScreens(
|
|||||||
// dev channels to any --channels list already set in main.tsx. Org policy
|
// dev channels to any --channels list already set in main.tsx. Org policy
|
||||||
// is NOT bypassed — gateChannelServer() still runs; this flag only exists
|
// is NOT bypassed — gateChannelServer() still runs; this flag only exists
|
||||||
// to sidestep the --channels approved-server allowlist.
|
// to sidestep the --channels approved-server allowlist.
|
||||||
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
|
if (devChannels && devChannels.length > 0) {
|
||||||
// gateChannelServer and ChannelsNotice read tengu_harbor after this
|
const { DevChannelsDialog } = await import(
|
||||||
// function returns. A cold disk cache (fresh install, or first run after
|
'./components/DevChannelsDialog.js'
|
||||||
// the flag was added server-side) defaults to false and silently drops
|
)
|
||||||
// channel notifications for the whole session — gh#37026.
|
await showSetupDialog(root, done => (
|
||||||
// checkGate_CACHED_OR_BLOCKING returns immediately if disk already says
|
<DevChannelsDialog
|
||||||
// true; only blocks on a cold/stale-false cache (awaits the same memoized
|
channels={devChannels}
|
||||||
// initializeGrowthBook promise fired earlier). Also warms the
|
onAccept={() => {
|
||||||
// isChannelsEnabled() check in the dev-channels dialog below.
|
// Mark dev entries per-entry so the allowlist bypass doesn't leak
|
||||||
if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) {
|
// to --channels entries when both flags are passed.
|
||||||
await checkGate_CACHED_OR_BLOCKING('tengu_harbor')
|
setAllowedChannels([
|
||||||
}
|
...getAllowedChannels(),
|
||||||
|
...devChannels.map(c => ({ ...c, dev: true })),
|
||||||
if (devChannels && devChannels.length > 0) {
|
])
|
||||||
const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] =
|
setHasDevChannels(true)
|
||||||
await Promise.all([
|
void done()
|
||||||
import('./services/mcp/channelAllowlist.js'),
|
}}
|
||||||
import('./utils/auth.js'),
|
/>
|
||||||
])
|
))
|
||||||
// Skip the dialog when channels are blocked (tengu_harbor off or no
|
|
||||||
// OAuth) — accepting then immediately seeing "not available" in
|
|
||||||
// ChannelsNotice is worse than no dialog. Append entries anyway so
|
|
||||||
// ChannelsNotice renders the blocked branch with the dev entries
|
|
||||||
// named. dev:true here is for the flag label in ChannelsNotice
|
|
||||||
// (hasNonDev check); the allowlist bypass it also grants is moot
|
|
||||||
// since the gate blocks upstream.
|
|
||||||
if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) {
|
|
||||||
setAllowedChannels([
|
|
||||||
...getAllowedChannels(),
|
|
||||||
...devChannels.map(c => ({ ...c, dev: true })),
|
|
||||||
])
|
|
||||||
setHasDevChannels(true)
|
|
||||||
} else {
|
|
||||||
const { DevChannelsDialog } = await import(
|
|
||||||
'./components/DevChannelsDialog.js'
|
|
||||||
)
|
|
||||||
await showSetupDialog(root, done => (
|
|
||||||
<DevChannelsDialog
|
|
||||||
channels={devChannels}
|
|
||||||
onAccept={() => {
|
|
||||||
// Mark dev entries per-entry so the allowlist bypass doesn't leak
|
|
||||||
// to --channels entries when both flags are passed.
|
|
||||||
setAllowedChannels([
|
|
||||||
...getAllowedChannels(),
|
|
||||||
...devChannels.map(c => ({ ...c, dev: true })),
|
|
||||||
])
|
|
||||||
setHasDevChannels(true)
|
|
||||||
void done()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show Chrome onboarding for first-time Claude in Chrome users
|
// Show Chrome onboarding for first-time Claude in Chrome users
|
||||||
|
|||||||
220
src/main.tsx
220
src/main.tsx
@@ -2558,111 +2558,109 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
// devChannels is deferred: showSetupScreens shows a confirmation dialog
|
// devChannels is deferred: showSetupScreens shows a confirmation dialog
|
||||||
// and only appends to allowedChannels on accept.
|
// and only appends to allowedChannels on accept.
|
||||||
let devChannels: ChannelEntry[] | undefined;
|
let devChannels: ChannelEntry[] | undefined;
|
||||||
if (feature("KAIROS") || feature("KAIROS_CHANNELS")) {
|
// Parse plugin:name@marketplace / server:Y tags into typed entries.
|
||||||
// Parse plugin:name@marketplace / server:Y tags into typed entries.
|
// Tag decides trust model downstream: plugin-kind hits marketplace
|
||||||
// Tag decides trust model downstream: plugin-kind hits marketplace
|
// verification + GrowthBook allowlist, server-kind always fails
|
||||||
// verification + GrowthBook allowlist, server-kind always fails
|
// allowlist (schema is plugin-only) unless dev flag is set.
|
||||||
// allowlist (schema is plugin-only) unless dev flag is set.
|
// Untagged or marketplace-less plugin entries are hard errors —
|
||||||
// Untagged or marketplace-less plugin entries are hard errors —
|
// silently not-matching in the gate would look like channels are
|
||||||
// silently not-matching in the gate would look like channels are
|
// "on" but nothing ever fires.
|
||||||
// "on" but nothing ever fires.
|
const parseChannelEntries = (
|
||||||
const parseChannelEntries = (
|
raw: string[],
|
||||||
raw: string[],
|
flag: string,
|
||||||
flag: string,
|
): ChannelEntry[] => {
|
||||||
): ChannelEntry[] => {
|
const entries: ChannelEntry[] = [];
|
||||||
const entries: ChannelEntry[] = [];
|
const bad: string[] = [];
|
||||||
const bad: string[] = [];
|
for (const c of raw) {
|
||||||
for (const c of raw) {
|
if (c.startsWith("plugin:")) {
|
||||||
if (c.startsWith("plugin:")) {
|
const rest = c.slice(7);
|
||||||
const rest = c.slice(7);
|
const at = rest.indexOf("@");
|
||||||
const at = rest.indexOf("@");
|
if (at <= 0 || at === rest.length - 1) {
|
||||||
if (at <= 0 || at === rest.length - 1) {
|
|
||||||
bad.push(c);
|
|
||||||
} else {
|
|
||||||
entries.push({
|
|
||||||
kind: "plugin",
|
|
||||||
name: rest.slice(0, at),
|
|
||||||
marketplace: rest.slice(at + 1),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (c.startsWith("server:") && c.length > 7) {
|
|
||||||
entries.push({ kind: "server", name: c.slice(7) });
|
|
||||||
} else {
|
|
||||||
bad.push(c);
|
bad.push(c);
|
||||||
|
} else {
|
||||||
|
entries.push({
|
||||||
|
kind: "plugin",
|
||||||
|
name: rest.slice(0, at),
|
||||||
|
marketplace: rest.slice(at + 1),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} else if (c.startsWith("server:") && c.length > 7) {
|
||||||
|
entries.push({ kind: "server", name: c.slice(7) });
|
||||||
|
} else {
|
||||||
|
bad.push(c);
|
||||||
}
|
}
|
||||||
if (bad.length > 0) {
|
}
|
||||||
process.stderr.write(
|
if (bad.length > 0) {
|
||||||
chalk.red(
|
process.stderr.write(
|
||||||
`${flag} entries must be tagged: ${bad.join(", ")}\n` +
|
chalk.red(
|
||||||
` plugin:<name>@<marketplace> — plugin-provided channel (allowlist enforced)\n` +
|
`${flag} entries must be tagged: ${bad.join(", ")}\n` +
|
||||||
` server:<name> — manually configured MCP server\n`,
|
` plugin:<name>@<marketplace> — plugin-provided channel (allowlist enforced)\n` +
|
||||||
),
|
` server:<name> — manually configured MCP server\n`,
|
||||||
);
|
),
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
};
|
|
||||||
|
|
||||||
const channelOpts = options as {
|
|
||||||
channels?: string[];
|
|
||||||
dangerouslyLoadDevelopmentChannels?: string[];
|
|
||||||
};
|
|
||||||
const rawChannels = channelOpts.channels;
|
|
||||||
const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels;
|
|
||||||
// Always parse + set. ChannelsNotice reads getAllowedChannels() and
|
|
||||||
// renders the appropriate branch (disabled/noAuth/policyBlocked/
|
|
||||||
// listening) in the startup screen. gateChannelServer() enforces.
|
|
||||||
// --channels works in both interactive and print/SDK modes; dev-channels
|
|
||||||
// stays interactive-only (requires a confirmation dialog).
|
|
||||||
let channelEntries: ChannelEntry[] = [];
|
|
||||||
if (rawChannels && rawChannels.length > 0) {
|
|
||||||
channelEntries = parseChannelEntries(
|
|
||||||
rawChannels,
|
|
||||||
"--channels",
|
|
||||||
);
|
);
|
||||||
setAllowedChannels(channelEntries);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!isNonInteractiveSession) {
|
return entries;
|
||||||
if (rawDev && rawDev.length > 0) {
|
};
|
||||||
devChannels = parseChannelEntries(
|
|
||||||
rawDev,
|
const channelOpts = options as {
|
||||||
"--dangerously-load-development-channels",
|
channels?: string[];
|
||||||
);
|
dangerouslyLoadDevelopmentChannels?: string[];
|
||||||
}
|
};
|
||||||
}
|
const rawChannels = channelOpts.channels;
|
||||||
// Flag-usage telemetry. Plugin identifiers are logged (same tier as
|
const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels;
|
||||||
// tengu_plugin_installed — public-registry-style names); server-kind
|
// Always parse + set. ChannelsNotice reads getAllowedChannels() and
|
||||||
// names are not (MCP-server-name tier, opt-in-only elsewhere).
|
// renders the appropriate branch (disabled/noAuth/policyBlocked/
|
||||||
// Per-server gate outcomes land in tengu_mcp_channel_gate once
|
// listening) in the startup screen. gateChannelServer() enforces.
|
||||||
// servers connect. Dev entries go through a confirmation dialog after
|
// --channels works in both interactive and print/SDK modes; dev-channels
|
||||||
// this — dev_plugins captures what was typed, not what was accepted.
|
// stays interactive-only (requires a confirmation dialog).
|
||||||
if (
|
let channelEntries: ChannelEntry[] = [];
|
||||||
channelEntries.length > 0 ||
|
if (rawChannels && rawChannels.length > 0) {
|
||||||
(devChannels?.length ?? 0) > 0
|
channelEntries = parseChannelEntries(
|
||||||
) {
|
rawChannels,
|
||||||
const joinPluginIds = (entries: ChannelEntry[]) => {
|
"--channels",
|
||||||
const ids = entries.flatMap((e) =>
|
);
|
||||||
e.kind === "plugin"
|
setAllowedChannels(channelEntries);
|
||||||
? [`${e.name}@${e.marketplace}`]
|
}
|
||||||
: [],
|
if (!isNonInteractiveSession) {
|
||||||
);
|
if (rawDev && rawDev.length > 0) {
|
||||||
return ids.length > 0
|
devChannels = parseChannelEntries(
|
||||||
? (ids
|
rawDev,
|
||||||
.sort()
|
"--dangerously-load-development-channels",
|
||||||
.join(
|
);
|
||||||
",",
|
|
||||||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
|
||||||
: undefined;
|
|
||||||
};
|
|
||||||
logEvent("tengu_mcp_channel_flags", {
|
|
||||||
channels_count: channelEntries.length,
|
|
||||||
dev_count: devChannels?.length ?? 0,
|
|
||||||
plugins: joinPluginIds(channelEntries),
|
|
||||||
dev_plugins: joinPluginIds(devChannels ?? []),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Flag-usage telemetry. Plugin identifiers are logged (same tier as
|
||||||
|
// tengu_plugin_installed — public-registry-style names); server-kind
|
||||||
|
// names are not (MCP-server-name tier, opt-in-only elsewhere).
|
||||||
|
// Per-server gate outcomes land in tengu_mcp_channel_gate once
|
||||||
|
// servers connect. Dev entries go through a confirmation dialog after
|
||||||
|
// this — dev_plugins captures what was typed, not what was accepted.
|
||||||
|
if (
|
||||||
|
channelEntries.length > 0 ||
|
||||||
|
(devChannels?.length ?? 0) > 0
|
||||||
|
) {
|
||||||
|
const joinPluginIds = (entries: ChannelEntry[]) => {
|
||||||
|
const ids = entries.flatMap((e) =>
|
||||||
|
e.kind === "plugin"
|
||||||
|
? [`${e.name}@${e.marketplace}`]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
return ids.length > 0
|
||||||
|
? (ids
|
||||||
|
.sort()
|
||||||
|
.join(
|
||||||
|
",",
|
||||||
|
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
logEvent("tengu_mcp_channel_flags", {
|
||||||
|
channels_count: channelEntries.length,
|
||||||
|
dev_count: devChannels?.length ?? 0,
|
||||||
|
plugins: joinPluginIds(channelEntries),
|
||||||
|
dev_plugins: joinPluginIds(devChannels ?? []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// SDK opt-in for SendUserMessage via --tools. All sessions require
|
// SDK opt-in for SendUserMessage via --tools. All sessions require
|
||||||
// explicit opt-in; listing it in --tools signals intent. Runs BEFORE
|
// explicit opt-in; listing it in --tools signals intent. Runs BEFORE
|
||||||
@@ -5627,20 +5625,18 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
).hideHelp(),
|
).hideHelp(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (feature("KAIROS") || feature("KAIROS_CHANNELS")) {
|
program.addOption(
|
||||||
program.addOption(
|
new Option(
|
||||||
new Option(
|
"--channels <servers...>",
|
||||||
"--channels <servers...>",
|
"MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.",
|
||||||
"MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.",
|
).hideHelp(),
|
||||||
).hideHelp(),
|
);
|
||||||
);
|
program.addOption(
|
||||||
program.addOption(
|
new Option(
|
||||||
new Option(
|
"--dangerously-load-development-channels <servers...>",
|
||||||
"--dangerously-load-development-channels <servers...>",
|
"Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.",
|
||||||
"Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.",
|
).hideHelp(),
|
||||||
).hideHelp(),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teammate identity options (set by leader when spawning tmux teammates)
|
// Teammate identity options (set by leader when spawning tmux teammates)
|
||||||
// These replace the CLAUDE_CODE_* environment variables
|
// These replace the CLAUDE_CODE_* environment variables
|
||||||
|
|||||||
@@ -44,12 +44,10 @@ export function getChannelAllowlist(): ChannelAllowlistEntry[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overall channels on/off. Checked before any per-server gating —
|
* Overall channels on/off. Always enabled — GrowthBook gate bypassed.
|
||||||
* when false, --channels is a no-op and no handlers register.
|
|
||||||
* Default false; GrowthBook 5-min refresh.
|
|
||||||
*/
|
*/
|
||||||
export function isChannelsEnabled(): boolean {
|
export function isChannelsEnabled(): boolean {
|
||||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor', false)
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { z } from 'zod/v4'
|
|||||||
import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js'
|
import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js'
|
||||||
import { CHANNEL_TAG } from '../../constants/xml.js'
|
import { CHANNEL_TAG } from '../../constants/xml.js'
|
||||||
import {
|
import {
|
||||||
getClaudeAIOAuthTokens,
|
|
||||||
getSubscriptionType,
|
getSubscriptionType,
|
||||||
} from '../../utils/auth.js'
|
} from '../../utils/auth.js'
|
||||||
import { lazySchema } from '../../utils/lazySchema.js'
|
import { lazySchema } from '../../utils/lazySchema.js'
|
||||||
@@ -205,45 +204,6 @@ export function gateChannelServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall runtime gate. After capability so normal MCP servers never hit
|
|
||||||
// this path. Before auth/policy so the killswitch works regardless of
|
|
||||||
// session state.
|
|
||||||
if (!isChannelsEnabled()) {
|
|
||||||
return {
|
|
||||||
action: 'skip',
|
|
||||||
kind: 'disabled',
|
|
||||||
reason: 'channels feature is not currently available',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuth-only. API key users (console) are blocked — there's no
|
|
||||||
// channelsEnabled admin surface in console yet, so the policy opt-in
|
|
||||||
// flow doesn't exist for them. Drop this when console parity lands.
|
|
||||||
if (!getClaudeAIOAuthTokens()?.accessToken) {
|
|
||||||
return {
|
|
||||||
action: 'skip',
|
|
||||||
kind: 'auth',
|
|
||||||
reason: 'channels requires claude.ai authentication (run /login)',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teams/Enterprise opt-in. Managed orgs must explicitly enable channels.
|
|
||||||
// Default OFF — absent or false blocks. Keyed off subscription tier, not
|
|
||||||
// "policy settings exist" — a team org with zero configured policy keys
|
|
||||||
// (remote endpoint returns 404) is still a managed org and must not fall
|
|
||||||
// through to the unmanaged path.
|
|
||||||
const sub = getSubscriptionType()
|
|
||||||
const managed = sub === 'team' || sub === 'enterprise'
|
|
||||||
const policy = managed ? getSettingsForSource('policySettings') : undefined
|
|
||||||
if (managed && policy?.channelsEnabled !== true) {
|
|
||||||
return {
|
|
||||||
action: 'skip',
|
|
||||||
kind: 'policy',
|
|
||||||
reason:
|
|
||||||
'channels not enabled by org policy (set channelsEnabled: true in managed settings)',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User-level session opt-in. A server must be explicitly listed in
|
// User-level session opt-in. A server must be explicitly listed in
|
||||||
// --channels to push inbound this session — protects against a trusted
|
// --channels to push inbound this session — protects against a trusted
|
||||||
// server surprise-adding the capability.
|
// server surprise-adding the capability.
|
||||||
@@ -275,41 +235,6 @@ export function gateChannelServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approved-plugin allowlist. Marketplace gate already verified
|
|
||||||
// tag == reality, so this is a pure entry check. entry.dev (per-entry,
|
|
||||||
// not the session-wide bit) bypasses — so accepting the dev dialog for
|
|
||||||
// one entry doesn't leak allowlist-bypass to --channels entries.
|
|
||||||
if (!entry.dev) {
|
|
||||||
const { entries, source } = getEffectiveChannelAllowlist(
|
|
||||||
sub,
|
|
||||||
policy?.allowedChannelPlugins,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
!entries.some(
|
|
||||||
e => e.plugin === entry.name && e.marketplace === entry.marketplace,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
action: 'skip',
|
|
||||||
kind: 'allowlist',
|
|
||||||
reason:
|
|
||||||
source === 'org'
|
|
||||||
? `plugin ${entry.name}@${entry.marketplace} is not on your org's approved channels list (set allowedChannelPlugins in managed settings)`
|
|
||||||
: `plugin ${entry.name}@${entry.marketplace} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// server-kind: allowlist schema is {marketplace, plugin} — a server entry
|
|
||||||
// can never match. Without this, --channels server:plugin:foo:bar would
|
|
||||||
// match a plugin's runtime name and register with no allowlist check.
|
|
||||||
if (!entry.dev) {
|
|
||||||
return {
|
|
||||||
action: 'skip',
|
|
||||||
kind: 'allowlist',
|
|
||||||
reason: `server ${entry.name} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { action: 'register' }
|
return { action: 'register' }
|
||||||
|
|||||||
@@ -470,147 +470,145 @@ export function useManageMCPConnections(
|
|||||||
// Channel push: notifications/claude/channel → enqueue().
|
// Channel push: notifications/claude/channel → enqueue().
|
||||||
// Gate decides whether to register the handler; connection stays
|
// Gate decides whether to register the handler; connection stays
|
||||||
// up either way (allowedMcpServers controls that).
|
// up either way (allowedMcpServers controls that).
|
||||||
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
|
const gate = gateChannelServer(
|
||||||
const gate = gateChannelServer(
|
client.name,
|
||||||
client.name,
|
client.capabilities,
|
||||||
client.capabilities,
|
client.config.pluginSource,
|
||||||
client.config.pluginSource,
|
)
|
||||||
)
|
const entry = findChannelEntry(client.name, getAllowedChannels())
|
||||||
const entry = findChannelEntry(client.name, getAllowedChannels())
|
// Plugin identifier for telemetry — log name@marketplace for any
|
||||||
// Plugin identifier for telemetry — log name@marketplace for any
|
// plugin-kind entry (same tier as tengu_plugin_installed, which
|
||||||
// plugin-kind entry (same tier as tengu_plugin_installed, which
|
// logs arbitrary plugin_id+marketplace_name ungated). server-kind
|
||||||
// logs arbitrary plugin_id+marketplace_name ungated). server-kind
|
// names are MCP-server-name tier; those are opt-in-only elsewhere
|
||||||
// names are MCP-server-name tier; those are opt-in-only elsewhere
|
// (see isAnalyticsToolDetailsLoggingEnabled in metadata.ts) and
|
||||||
// (see isAnalyticsToolDetailsLoggingEnabled in metadata.ts) and
|
// stay unlogged here. is_dev/entry_kind segment the rest.
|
||||||
// stay unlogged here. is_dev/entry_kind segment the rest.
|
const pluginId =
|
||||||
const pluginId =
|
entry?.kind === 'plugin'
|
||||||
entry?.kind === 'plugin'
|
? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||||||
? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
: undefined
|
||||||
: undefined
|
// Skip capability-miss — every non-channel MCP server trips it.
|
||||||
// Skip capability-miss — every non-channel MCP server trips it.
|
if (gate.action === 'register' || gate.kind !== 'capability') {
|
||||||
if (gate.action === 'register' || gate.kind !== 'capability') {
|
logEvent('tengu_mcp_channel_gate', {
|
||||||
logEvent('tengu_mcp_channel_gate', {
|
registered: gate.action === 'register',
|
||||||
registered: gate.action === 'register',
|
skip_kind:
|
||||||
skip_kind:
|
gate.action === 'skip'
|
||||||
gate.action === 'skip'
|
? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
||||||
? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
: undefined,
|
||||||
: undefined,
|
entry_kind:
|
||||||
entry_kind:
|
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
is_dev: entry?.dev ?? false,
|
||||||
is_dev: entry?.dev ?? false,
|
plugin: pluginId,
|
||||||
plugin: pluginId,
|
})
|
||||||
})
|
}
|
||||||
}
|
switch (gate.action) {
|
||||||
switch (gate.action) {
|
case 'register':
|
||||||
case 'register':
|
logMCPDebug(client.name, 'Channel notifications registered')
|
||||||
logMCPDebug(client.name, 'Channel notifications registered')
|
client.client.setNotificationHandler(
|
||||||
|
ChannelMessageNotificationSchema(),
|
||||||
|
async notification => {
|
||||||
|
const { content, meta } = notification.params
|
||||||
|
logMCPDebug(
|
||||||
|
client.name,
|
||||||
|
`notifications/claude/channel: ${content.slice(0, 80)}`,
|
||||||
|
)
|
||||||
|
logEvent('tengu_mcp_channel_message', {
|
||||||
|
content_length: content.length,
|
||||||
|
meta_key_count: Object.keys(meta ?? {}).length,
|
||||||
|
entry_kind:
|
||||||
|
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
|
is_dev: entry?.dev ?? false,
|
||||||
|
plugin: pluginId,
|
||||||
|
})
|
||||||
|
enqueue({
|
||||||
|
mode: 'prompt',
|
||||||
|
value: wrapChannelMessage(client.name, content, meta),
|
||||||
|
priority: 'next',
|
||||||
|
isMeta: true,
|
||||||
|
origin: { kind: 'channel', server: client.name } as any,
|
||||||
|
skipSlashCommands: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Permission-reply handler — separate event, separate
|
||||||
|
// capability. Only registers if the server declares
|
||||||
|
// claude/channel/permission (same opt-in check as the send
|
||||||
|
// path in interactiveHandler.ts). Server parses the user's
|
||||||
|
// reply and emits {request_id, behavior}; no regex on our
|
||||||
|
// side, text in the general channel can't accidentally match.
|
||||||
|
if (
|
||||||
|
client.capabilities?.experimental?.[
|
||||||
|
'claude/channel/permission'
|
||||||
|
] !== undefined
|
||||||
|
) {
|
||||||
client.client.setNotificationHandler(
|
client.client.setNotificationHandler(
|
||||||
ChannelMessageNotificationSchema(),
|
ChannelPermissionNotificationSchema(),
|
||||||
async notification => {
|
async notification => {
|
||||||
const { content, meta } = notification.params
|
const { request_id, behavior } = notification.params
|
||||||
|
const resolved =
|
||||||
|
channelPermCallbacksRef.current?.resolve(
|
||||||
|
request_id,
|
||||||
|
behavior,
|
||||||
|
client.name,
|
||||||
|
) ?? false
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
client.name,
|
client.name,
|
||||||
`notifications/claude/channel: ${content.slice(0, 80)}`,
|
`notifications/claude/channel/permission: ${request_id} → ${behavior} (${resolved ? 'matched pending' : 'no pending entry — stale or unknown ID'})`,
|
||||||
)
|
)
|
||||||
logEvent('tengu_mcp_channel_message', {
|
|
||||||
content_length: content.length,
|
|
||||||
meta_key_count: Object.keys(meta ?? {}).length,
|
|
||||||
entry_kind:
|
|
||||||
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
||||||
is_dev: entry?.dev ?? false,
|
|
||||||
plugin: pluginId,
|
|
||||||
})
|
|
||||||
enqueue({
|
|
||||||
mode: 'prompt',
|
|
||||||
value: wrapChannelMessage(client.name, content, meta),
|
|
||||||
priority: 'next',
|
|
||||||
isMeta: true,
|
|
||||||
origin: { kind: 'channel', server: client.name } as any,
|
|
||||||
skipSlashCommands: true,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Permission-reply handler — separate event, separate
|
}
|
||||||
// capability. Only registers if the server declares
|
break
|
||||||
// claude/channel/permission (same opt-in check as the send
|
case 'skip':
|
||||||
// path in interactiveHandler.ts). Server parses the user's
|
// Idempotent teardown so a register→skip re-gate (e.g.
|
||||||
// reply and emits {request_id, behavior}; no regex on our
|
// effect re-runs after /logout) actually removes the live
|
||||||
// side, text in the general channel can't accidentally match.
|
// handler. Without this, mid-session demotion is one-way:
|
||||||
if (
|
// the gate says skip but the earlier handler keeps enqueuing.
|
||||||
client.capabilities?.experimental?.[
|
// Map.delete — safe when never registered.
|
||||||
'claude/channel/permission'
|
client.client.removeNotificationHandler(
|
||||||
] !== undefined
|
'notifications/claude/channel',
|
||||||
) {
|
)
|
||||||
client.client.setNotificationHandler(
|
client.client.removeNotificationHandler(
|
||||||
ChannelPermissionNotificationSchema(),
|
CHANNEL_PERMISSION_METHOD,
|
||||||
async notification => {
|
)
|
||||||
const { request_id, behavior } = notification.params
|
logMCPDebug(
|
||||||
const resolved =
|
client.name,
|
||||||
channelPermCallbacksRef.current?.resolve(
|
`Channel notifications skipped: ${gate.reason}`,
|
||||||
request_id,
|
)
|
||||||
behavior,
|
// Surface a once-per-kind toast when a channel server is
|
||||||
client.name,
|
// blocked. This is the only
|
||||||
) ?? false
|
// user-visible signal (logMCPDebug above requires --debug).
|
||||||
logMCPDebug(
|
// Capability/session skips are expected noise and stay
|
||||||
client.name,
|
// debug-only. marketplace/allowlist run after session — if
|
||||||
`notifications/claude/channel/permission: ${request_id} → ${behavior} (${resolved ? 'matched pending' : 'no pending entry — stale or unknown ID'})`,
|
// we're here with those kinds, the user asked for it.
|
||||||
)
|
if (
|
||||||
},
|
gate.kind !== 'capability' &&
|
||||||
)
|
gate.kind !== 'session' &&
|
||||||
}
|
!channelWarnedKindsRef.current.has(gate.kind) &&
|
||||||
break
|
(gate.kind === 'marketplace' ||
|
||||||
case 'skip':
|
gate.kind === 'allowlist' ||
|
||||||
// Idempotent teardown so a register→skip re-gate (e.g.
|
entry !== undefined)
|
||||||
// effect re-runs after /logout) actually removes the live
|
) {
|
||||||
// handler. Without this, mid-session demotion is one-way:
|
channelWarnedKindsRef.current.add(gate.kind)
|
||||||
// the gate says skip but the earlier handler keeps enqueuing.
|
// disabled/auth/policy get custom toast copy (shorter, actionable);
|
||||||
// Map.delete — safe when never registered.
|
// marketplace/allowlist reuse the gate's reason verbatim
|
||||||
client.client.removeNotificationHandler(
|
// since it already names the mismatch.
|
||||||
'notifications/claude/channel',
|
const text =
|
||||||
)
|
gate.kind === 'disabled'
|
||||||
client.client.removeNotificationHandler(
|
? 'Channels are not currently available'
|
||||||
CHANNEL_PERMISSION_METHOD,
|
: gate.kind === 'auth'
|
||||||
)
|
? 'Channels require claude.ai authentication · run /login'
|
||||||
logMCPDebug(
|
: gate.kind === 'policy'
|
||||||
client.name,
|
? 'Channels are not enabled for your org · have an administrator set channelsEnabled: true in managed settings'
|
||||||
`Channel notifications skipped: ${gate.reason}`,
|
: gate.reason
|
||||||
)
|
addNotification({
|
||||||
// Surface a once-per-kind toast when a channel server is
|
key: `channels-blocked-${gate.kind}`,
|
||||||
// blocked. This is the only
|
priority: 'high',
|
||||||
// user-visible signal (logMCPDebug above requires --debug).
|
text,
|
||||||
// Capability/session skips are expected noise and stay
|
color: 'warning',
|
||||||
// debug-only. marketplace/allowlist run after session — if
|
timeoutMs: 12000,
|
||||||
// we're here with those kinds, the user asked for it.
|
})
|
||||||
if (
|
}
|
||||||
gate.kind !== 'capability' &&
|
break
|
||||||
gate.kind !== 'session' &&
|
|
||||||
!channelWarnedKindsRef.current.has(gate.kind) &&
|
|
||||||
(gate.kind === 'marketplace' ||
|
|
||||||
gate.kind === 'allowlist' ||
|
|
||||||
entry !== undefined)
|
|
||||||
) {
|
|
||||||
channelWarnedKindsRef.current.add(gate.kind)
|
|
||||||
// disabled/auth/policy get custom toast copy (shorter, actionable);
|
|
||||||
// marketplace/allowlist reuse the gate's reason verbatim
|
|
||||||
// since it already names the mismatch.
|
|
||||||
const text =
|
|
||||||
gate.kind === 'disabled'
|
|
||||||
? 'Channels are not currently available'
|
|
||||||
: gate.kind === 'auth'
|
|
||||||
? 'Channels require claude.ai authentication · run /login'
|
|
||||||
: gate.kind === 'policy'
|
|
||||||
? 'Channels are not enabled for your org · have an administrator set channelsEnabled: true in managed settings'
|
|
||||||
: gate.reason
|
|
||||||
addNotification({
|
|
||||||
key: `channels-blocked-${gate.kind}`,
|
|
||||||
priority: 'high',
|
|
||||||
text,
|
|
||||||
color: 'warning',
|
|
||||||
timeoutMs: 12000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register notification handlers for list_changed notifications
|
// Register notification handlers for list_changed notifications
|
||||||
|
|||||||
Reference in New Issue
Block a user