mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
* feat: 接入 weixin 服务层与命令入口 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat: 注册内建 weixin channel 插件 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 修正 channel permission relay 路由与能力判定 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 修复 builtin channel 的 ChannelsNotice 误报 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: 补充内建 weixin channel 使用说明 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: 更新微信 channel 接入计划状态 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 延迟加载 weixin 登录二维码依赖 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 改用 qrcode 生成 weixin 登录二维码 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 修正 vite 构建的 Windows 路径解析 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * chore: 删除临时规划文档 wx_channel.md 并还原 package.json 排序 wx_channel.md 内容已整合到 docs/features/channels.md,不再需要。 package.json 中 @ant/model-provider 位置从原始位置被无意移动,还原。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 将 weixin 模块从 src/ 迁移至 packages/weixin 工作区包 将 src/services/weixin/ 中的纯业务逻辑迁入 @claude-code-best/weixin workspace 包,降低 src/ 耦合度。仅保留 server.ts 作为薄适配层。 - 迁移 7 个无修改的纯模块 (types/api/accounts/login/pairing/media/send) - monitor.ts 内联 PERMISSION_REPLY_RE 正则,解除对 src/ 的依赖 - permissions.ts 本地定义 ChannelPermissionRequestParams 接口 - cli.ts 拆分:serve 子命令通过回调注入,login/access 保留在包内 - server.ts 重写为从 @claude-code-best/weixin 导入 - 新增 cli-serve.ts 作为 serve 入口薄壳 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修正 weixin barrel export 中 interface 的导出方式 ChannelPermissionRequestParams 是纯类型,必须用 export type 导出, 否则 Bun 运行时会报 "export not found" 错误。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 将 server.ts 迁入 packages/weixin,彻底移除 src/services/weixin/ 通过依赖注入(WeixinServerDeps)解耦 src/ 依赖(analytics、config、 MCP channel schema),server.ts 完全移入包内。cli.tsx 入口处一次性 注入所有依赖。 src/services/weixin/ 目录已完全删除。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 markdownToPlainText 中代码块正则的 ReDoS 风险 用非正则的线性扫描替代 \`\`\`[\s\S]*?\n([\s\S]*?)\`\`\` 匹配, 避免在含有大量重复 \`\`\` 序列的输入上触发多项式回溯。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: 1111 <11111@asd.c> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
711 lines
25 KiB
TypeScript
711 lines
25 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
|
import { randomUUID } from 'crypto'
|
|
import { CHANNEL_TAG } from 'src/constants/xml.js'
|
|
import { logForDebugging } from 'src/utils/debug.js'
|
|
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
|
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
|
import type { ToolUseConfirm } from '../../../components/permissions/PermissionRequest.js'
|
|
import { getTerminalFocused } from '@anthropic/ink'
|
|
import {
|
|
CHANNEL_PERMISSION_REQUEST_METHOD,
|
|
type ChannelPermissionRequestParams,
|
|
findChannelEntry,
|
|
} from '../../../services/mcp/channelNotification.js'
|
|
import type { ChannelPermissionCallbacks } from '../../../services/mcp/channelPermissions.js'
|
|
import {
|
|
filterPermissionRelayClients,
|
|
shortRequestId,
|
|
truncateForPreview,
|
|
} from '../../../services/mcp/channelPermissions.js'
|
|
import { executeAsyncClassifierCheck } from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js'
|
|
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
|
|
import {
|
|
clearClassifierChecking,
|
|
setClassifierApproval,
|
|
setClassifierChecking,
|
|
setYoloClassifierApproval,
|
|
} from '../../../utils/classifierApprovals.js'
|
|
import { errorMessage } from '../../../utils/errors.js'
|
|
import {
|
|
forgetPipePermissionRequest,
|
|
notifyPipePermissionCancel,
|
|
tryRelayPipePermissionRequest,
|
|
} from '../../../utils/pipePermissionRelay.js'
|
|
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
|
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
|
import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'
|
|
import type { PermissionContext } from '../PermissionContext.js'
|
|
import { createResolveOnce } from '../PermissionContext.js'
|
|
|
|
type InteractivePermissionParams = {
|
|
ctx: PermissionContext
|
|
description: string
|
|
result: PermissionDecision & { behavior: 'ask' }
|
|
awaitAutomatedChecksBeforeDialog: boolean | undefined
|
|
bridgeCallbacks?: BridgePermissionCallbacks
|
|
channelCallbacks?: ChannelPermissionCallbacks
|
|
}
|
|
|
|
type ChannelContextHint = {
|
|
sourceServer?: string
|
|
chatId?: string
|
|
}
|
|
|
|
function getTextBlocksText(content: unknown): string {
|
|
if (typeof content === 'string') {
|
|
return content
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return ''
|
|
}
|
|
return content
|
|
.filter(
|
|
(block): block is { type: 'text'; text: string } =>
|
|
typeof block === 'object' &&
|
|
block !== null &&
|
|
(block as { type?: unknown }).type === 'text' &&
|
|
typeof (block as { text?: unknown }).text === 'string',
|
|
)
|
|
.map(block => block.text)
|
|
.join('\n')
|
|
}
|
|
|
|
function parseChannelContextHintFromText(text: string): ChannelContextHint | null {
|
|
const tagMatch = text.match(new RegExp(`<${CHANNEL_TAG}\\b([^>]*)>`))
|
|
if (!tagMatch?.[1]) {
|
|
return null
|
|
}
|
|
|
|
const attrs = tagMatch[1]
|
|
const sourceServer = attrs.match(/\bsource="([^"]+)"/)?.[1]
|
|
const chatId = attrs.match(/\bchat_id="([^"]+)"/)?.[1]
|
|
|
|
if (!sourceServer && !chatId) {
|
|
return null
|
|
}
|
|
|
|
return { sourceServer, chatId }
|
|
}
|
|
|
|
export function getLatestChannelContextHint(messages: readonly unknown[]): ChannelContextHint | null {
|
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
const message = messages[index] as {
|
|
type?: unknown
|
|
origin?: { kind?: unknown; server?: unknown }
|
|
message?: { content?: unknown }
|
|
}
|
|
|
|
if (message?.type !== 'user' || message?.origin?.kind !== 'channel') {
|
|
continue
|
|
}
|
|
|
|
const text = getTextBlocksText(message.message?.content)
|
|
const parsed = parseChannelContextHintFromText(text)
|
|
if (parsed) {
|
|
return {
|
|
sourceServer:
|
|
parsed.sourceServer ||
|
|
(typeof message.origin.server === 'string'
|
|
? message.origin.server
|
|
: undefined),
|
|
chatId: parsed.chatId,
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Handles the interactive (main-agent) permission flow.
|
|
*
|
|
* Pushes a ToolUseConfirm entry to the confirm queue with callbacks:
|
|
* onAbort, onAllow, onReject, recheckPermission, onUserInteraction.
|
|
*
|
|
* Runs permission hooks and bash classifier checks asynchronously in the
|
|
* background, racing them against user interaction. Uses a resolve-once
|
|
* guard and `userInteracted` flag to prevent multiple resolutions.
|
|
*
|
|
* This function does NOT return a Promise -- it sets up callbacks that
|
|
* eventually call `resolve()` to resolve the outer promise owned by
|
|
* the caller.
|
|
*/
|
|
function handleInteractivePermission(
|
|
params: InteractivePermissionParams,
|
|
resolve: (decision: PermissionDecision) => void,
|
|
): void {
|
|
const {
|
|
ctx,
|
|
description,
|
|
result,
|
|
awaitAutomatedChecksBeforeDialog,
|
|
bridgeCallbacks,
|
|
channelCallbacks,
|
|
} = params
|
|
|
|
const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve)
|
|
let userInteracted = false
|
|
let checkmarkTransitionTimer: ReturnType<typeof setTimeout> | undefined
|
|
// Hoisted so onDismissCheckmark (Esc during checkmark window) can also
|
|
// remove the abort listener — not just the timer callback.
|
|
let checkmarkAbortHandler: (() => void) | undefined
|
|
const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined
|
|
// Hoisted so local/hook/classifier wins can remove the pending channel
|
|
// entry. No "tell remote to dismiss" equivalent — the text sits in your
|
|
// phone, and a stale "yes abc123" after local-resolve falls through
|
|
// tryConsumeReply (entry gone) and gets enqueued as normal chat.
|
|
let channelUnsubscribe: (() => void) | undefined
|
|
|
|
const permissionPromptStartTimeMs = Date.now()
|
|
const displayInput = result.updatedInput ?? ctx.input
|
|
let pipePermissionRequestId: string | null = null
|
|
|
|
function forgetPipePermission(reason?: string): void {
|
|
notifyPipePermissionCancel(pipePermissionRequestId, reason)
|
|
forgetPipePermissionRequest(pipePermissionRequestId)
|
|
pipePermissionRequestId = null
|
|
}
|
|
|
|
function forgetPipePermissionSilently(): void {
|
|
forgetPipePermissionRequest(pipePermissionRequestId)
|
|
pipePermissionRequestId = null
|
|
}
|
|
|
|
function clearClassifierIndicator(): void {
|
|
if (feature('BASH_CLASSIFIER')) {
|
|
ctx.updateQueueItem({ classifierCheckInProgress: false })
|
|
}
|
|
}
|
|
|
|
const toolUseConfirm: ToolUseConfirm = {
|
|
assistantMessage: ctx.assistantMessage,
|
|
tool: ctx.tool,
|
|
description,
|
|
input: displayInput,
|
|
toolUseContext: ctx.toolUseContext,
|
|
toolUseID: ctx.toolUseID,
|
|
permissionResult: result,
|
|
permissionPromptStartTimeMs,
|
|
...(feature('BASH_CLASSIFIER')
|
|
? {
|
|
classifierCheckInProgress:
|
|
!!result.pendingClassifierCheck &&
|
|
!awaitAutomatedChecksBeforeDialog,
|
|
}
|
|
: {}),
|
|
onUserInteraction() {
|
|
// Called when user starts interacting with the permission dialog
|
|
// (e.g., arrow keys, tab, typing feedback)
|
|
// Hide the classifier indicator since auto-approve is no longer possible
|
|
//
|
|
// Grace period: ignore interactions in the first 200ms to prevent
|
|
// accidental keypresses from canceling the classifier prematurely
|
|
const GRACE_PERIOD_MS = 200
|
|
if (Date.now() - permissionPromptStartTimeMs < GRACE_PERIOD_MS) {
|
|
return
|
|
}
|
|
userInteracted = true
|
|
clearClassifierChecking(ctx.toolUseID)
|
|
clearClassifierIndicator()
|
|
},
|
|
onDismissCheckmark() {
|
|
if (checkmarkTransitionTimer) {
|
|
clearTimeout(checkmarkTransitionTimer)
|
|
checkmarkTransitionTimer = undefined
|
|
if (checkmarkAbortHandler) {
|
|
ctx.toolUseContext.abortController.signal.removeEventListener(
|
|
'abort',
|
|
checkmarkAbortHandler,
|
|
)
|
|
checkmarkAbortHandler = undefined
|
|
}
|
|
ctx.removeFromQueue()
|
|
}
|
|
},
|
|
onAbort() {
|
|
if (!claim()) return
|
|
forgetPipePermission('Permission request was aborted locally in sub.')
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
|
behavior: 'deny',
|
|
message: 'User aborted',
|
|
})
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
channelUnsubscribe?.()
|
|
ctx.logCancelled()
|
|
ctx.logDecision(
|
|
{ decision: 'reject', source: { type: 'user_abort' } },
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(ctx.cancelAndAbort(undefined, true))
|
|
},
|
|
async onAllow(
|
|
updatedInput,
|
|
permissionUpdates: PermissionUpdate[],
|
|
feedback?: string,
|
|
contentBlocks?: ContentBlockParam[],
|
|
) {
|
|
if (!claim()) return // atomic check-and-mark before await
|
|
forgetPipePermission('Permission request was approved locally in sub.')
|
|
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
|
behavior: 'allow',
|
|
updatedInput,
|
|
updatedPermissions: permissionUpdates,
|
|
})
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
channelUnsubscribe?.()
|
|
|
|
resolveOnce(
|
|
await ctx.handleUserAllow(
|
|
updatedInput,
|
|
permissionUpdates,
|
|
feedback,
|
|
permissionPromptStartTimeMs,
|
|
contentBlocks,
|
|
result.decisionReason,
|
|
),
|
|
)
|
|
},
|
|
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
|
if (!claim()) return
|
|
forgetPipePermission('Permission request was rejected locally in sub.')
|
|
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
|
behavior: 'deny',
|
|
message: feedback ?? 'User denied permission',
|
|
})
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
channelUnsubscribe?.()
|
|
|
|
ctx.logDecision(
|
|
{
|
|
decision: 'reject',
|
|
source: { type: 'user_reject', hasFeedback: !!feedback },
|
|
},
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks))
|
|
},
|
|
async recheckPermission() {
|
|
if (isResolved()) return
|
|
const freshResult = await hasPermissionsToUseTool(
|
|
ctx.tool,
|
|
ctx.input,
|
|
ctx.toolUseContext,
|
|
ctx.assistantMessage,
|
|
ctx.toolUseID,
|
|
)
|
|
if (freshResult.behavior === 'allow') {
|
|
// claim() (atomic check-and-mark), not isResolved() — the async
|
|
// hasPermissionsToUseTool call above opens a window where CCR
|
|
// could have responded in flight. Matches onAllow/onReject/hook
|
|
// paths. cancelRequest tells CCR to dismiss its prompt — without
|
|
// it, the web UI shows a stale prompt for a tool that's already
|
|
// executing (particularly visible when recheck is triggered by
|
|
// a CCR-initiated mode switch, the very case this callback exists
|
|
// for after useReplBridge started calling it).
|
|
if (!claim()) return
|
|
forgetPipePermission('Permission request was resolved locally in sub.')
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
channelUnsubscribe?.()
|
|
ctx.removeFromQueue()
|
|
ctx.logDecision({ decision: 'accept', source: 'config' })
|
|
resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
|
|
}
|
|
},
|
|
}
|
|
|
|
ctx.pushToQueue(toolUseConfirm)
|
|
pipePermissionRequestId = tryRelayPipePermissionRequest(
|
|
toolUseConfirm,
|
|
response => {
|
|
if (!claim()) return
|
|
forgetPipePermissionSilently()
|
|
clearClassifierChecking(ctx.toolUseID)
|
|
clearClassifierIndicator()
|
|
ctx.removeFromQueue()
|
|
channelUnsubscribe?.()
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
|
|
if (response.behavior === 'allow') {
|
|
void (async () => {
|
|
if (response.permissionUpdates?.length) {
|
|
void ctx.persistPermissions(response.permissionUpdates)
|
|
}
|
|
ctx.logDecision(
|
|
{
|
|
decision: 'accept',
|
|
source: {
|
|
type: 'user',
|
|
permanent: !!response.permissionUpdates?.length,
|
|
},
|
|
},
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(
|
|
ctx.buildAllow(response.updatedInput ?? displayInput, {
|
|
acceptFeedback: response.feedback,
|
|
contentBlocks: response.contentBlocks,
|
|
}),
|
|
)
|
|
})()
|
|
} else {
|
|
ctx.logDecision(
|
|
{
|
|
decision: 'reject',
|
|
source: {
|
|
type: 'user_reject',
|
|
hasFeedback: !!response.feedback,
|
|
},
|
|
},
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(
|
|
ctx.cancelAndAbort(
|
|
response.feedback,
|
|
undefined,
|
|
response.contentBlocks,
|
|
),
|
|
)
|
|
}
|
|
},
|
|
)
|
|
|
|
// Race 4: Bridge permission response from CCR (claude.ai)
|
|
// When the bridge is connected, send the permission request to CCR and
|
|
// subscribe for a response. Whichever side (CLI or CCR) responds first
|
|
// wins via claim().
|
|
//
|
|
// All tools are forwarded — CCR's generic allow/deny modal handles any
|
|
// tool, and can return `updatedInput` when it has a dedicated renderer
|
|
// (e.g. plan edit). Tools whose local dialog injects fields (ReviewArtifact
|
|
// `selected`, AskUserQuestion `answers`) tolerate the field being missing
|
|
// so generic remote approval degrades gracefully instead of throwing.
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.sendRequest(
|
|
bridgeRequestId,
|
|
ctx.tool.name,
|
|
displayInput,
|
|
ctx.toolUseID,
|
|
description,
|
|
result.suggestions,
|
|
result.blockedPath,
|
|
)
|
|
|
|
const signal = ctx.toolUseContext.abortController.signal
|
|
const unsubscribe = bridgeCallbacks.onResponse(
|
|
bridgeRequestId,
|
|
response => {
|
|
if (!claim()) return // Local user/hook/classifier already responded
|
|
forgetPipePermission(
|
|
'Permission request was resolved by bridge before pipe response.',
|
|
)
|
|
signal.removeEventListener('abort', unsubscribe)
|
|
clearClassifierChecking(ctx.toolUseID)
|
|
clearClassifierIndicator()
|
|
ctx.removeFromQueue()
|
|
channelUnsubscribe?.()
|
|
|
|
if (response.behavior === 'allow') {
|
|
if (response.updatedPermissions?.length) {
|
|
void ctx.persistPermissions(response.updatedPermissions)
|
|
}
|
|
ctx.logDecision(
|
|
{
|
|
decision: 'accept',
|
|
source: {
|
|
type: 'user',
|
|
permanent: !!response.updatedPermissions?.length,
|
|
},
|
|
},
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(ctx.buildAllow(response.updatedInput ?? displayInput))
|
|
} else {
|
|
ctx.logDecision(
|
|
{
|
|
decision: 'reject',
|
|
source: {
|
|
type: 'user_reject',
|
|
hasFeedback: !!response.message,
|
|
},
|
|
},
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(ctx.cancelAndAbort(response.message))
|
|
}
|
|
},
|
|
)
|
|
|
|
signal.addEventListener('abort', unsubscribe, { once: true })
|
|
}
|
|
|
|
// Channel permission relay — races alongside the bridge block above. Send a
|
|
// permission prompt to every active channel (Telegram, iMessage, etc.) via
|
|
// its MCP send_message tool, then race the reply against local/bridge/hook/
|
|
// classifier. The inbound "yes abc123" is intercepted in the notification
|
|
// handler (useManageMCPConnections.ts) BEFORE enqueue, so it never reaches
|
|
// Claude as a conversation turn.
|
|
//
|
|
// Unlike the bridge block, this still guards on `requiresUserInteraction` —
|
|
// channel replies are pure yes/no with no `updatedInput` path. In practice
|
|
// the guard is dead code today: all three `requiresUserInteraction` tools
|
|
// (ExitPlanMode, AskUserQuestion, ReviewArtifact) return `isEnabled()===false`
|
|
// when channels are configured, so they never reach this handler.
|
|
//
|
|
// Fire-and-forget send: if callTool fails (channel down, tool missing),
|
|
// the subscription never fires and another racer wins. Graceful degradation
|
|
// — the local dialog is always there as the floor.
|
|
if (
|
|
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
|
|
channelCallbacks &&
|
|
!ctx.tool.requiresUserInteraction?.()
|
|
) {
|
|
const channelRequestId = shortRequestId(ctx.toolUseID)
|
|
const allowedChannels = getAllowedChannels()
|
|
const channelClients = filterPermissionRelayClients(
|
|
ctx.toolUseContext.getAppState().mcp.clients,
|
|
name => findChannelEntry(name, allowedChannels) !== undefined,
|
|
)
|
|
|
|
if (channelClients.length > 0) {
|
|
// Outbound is structured too (Kenneth's symmetry ask) — server owns
|
|
// message formatting for its platform (Telegram markdown, iMessage
|
|
// rich text, Discord embed). CC sends the RAW parts; server composes.
|
|
// The old callTool('send_message', {text,content,message}) triple-key
|
|
// hack is gone — no more guessing which arg name each plugin takes.
|
|
const params: ChannelPermissionRequestParams = {
|
|
request_id: channelRequestId,
|
|
tool_name: ctx.tool.name,
|
|
description,
|
|
input_preview: truncateForPreview(displayInput),
|
|
}
|
|
const channelContext = getLatestChannelContextHint(
|
|
ctx.toolUseContext.messages,
|
|
)
|
|
if (channelContext?.sourceServer || channelContext?.chatId) {
|
|
params.channel_context = {
|
|
...(channelContext.sourceServer && {
|
|
source_server: channelContext.sourceServer,
|
|
}),
|
|
...(channelContext.chatId && { chat_id: channelContext.chatId }),
|
|
}
|
|
}
|
|
|
|
for (const client of channelClients) {
|
|
if (client.type !== 'connected') continue // refine for TS
|
|
void client.client
|
|
.notification({
|
|
method: CHANNEL_PERMISSION_REQUEST_METHOD,
|
|
params,
|
|
})
|
|
.catch(e => {
|
|
logForDebugging(
|
|
`Channel permission_request failed for ${client.name}: ${errorMessage(e)}`,
|
|
{ level: 'error' },
|
|
)
|
|
})
|
|
}
|
|
|
|
const channelSignal = ctx.toolUseContext.abortController.signal
|
|
// Wrap so BOTH the map delete AND the abort-listener teardown happen
|
|
// at every call site. The 6 channelUnsubscribe?.() sites after local/
|
|
// hook/classifier wins previously only deleted the map entry — the
|
|
// dead closure stayed registered on the session-scoped abort signal
|
|
// until the session ended. Not a functional bug (Map.delete is
|
|
// idempotent), but it held the closure alive.
|
|
const mapUnsub = channelCallbacks.onResponse(
|
|
channelRequestId,
|
|
response => {
|
|
if (!claim()) return // Another racer won
|
|
forgetPipePermission(
|
|
'Permission request was resolved by channel before pipe response.',
|
|
)
|
|
channelUnsubscribe?.() // both: map delete + listener remove
|
|
clearClassifierChecking(ctx.toolUseID)
|
|
clearClassifierIndicator()
|
|
ctx.removeFromQueue()
|
|
// Bridge is the other remote — tell it we're done.
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
|
|
if (response.behavior === 'allow') {
|
|
ctx.logDecision(
|
|
{
|
|
decision: 'accept',
|
|
source: { type: 'user', permanent: false },
|
|
},
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(ctx.buildAllow(displayInput))
|
|
} else {
|
|
ctx.logDecision(
|
|
{
|
|
decision: 'reject',
|
|
source: { type: 'user_reject', hasFeedback: false },
|
|
},
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(
|
|
ctx.cancelAndAbort(`Denied via channel ${response.fromServer}`),
|
|
)
|
|
}
|
|
},
|
|
)
|
|
channelUnsubscribe = () => {
|
|
mapUnsub()
|
|
channelSignal.removeEventListener('abort', channelUnsubscribe!)
|
|
}
|
|
|
|
channelSignal.addEventListener('abort', channelUnsubscribe, {
|
|
once: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Skip hooks if they were already awaited in the coordinator branch above
|
|
if (!awaitAutomatedChecksBeforeDialog) {
|
|
// Execute PermissionRequest hooks asynchronously
|
|
// If hook returns a decision before user responds, apply it
|
|
void (async () => {
|
|
if (isResolved()) return
|
|
const currentAppState = ctx.toolUseContext.getAppState()
|
|
const hookDecision = await ctx.runHooks(
|
|
currentAppState.toolPermissionContext.mode,
|
|
result.suggestions,
|
|
result.updatedInput,
|
|
permissionPromptStartTimeMs,
|
|
)
|
|
if (!hookDecision || !claim()) return
|
|
forgetPipePermission(
|
|
'Permission request was resolved by hook before pipe response.',
|
|
)
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
channelUnsubscribe?.()
|
|
ctx.removeFromQueue()
|
|
resolveOnce(hookDecision)
|
|
})()
|
|
}
|
|
|
|
// Execute bash classifier check asynchronously (if applicable)
|
|
if (
|
|
feature('BASH_CLASSIFIER') &&
|
|
result.pendingClassifierCheck &&
|
|
ctx.tool.name === BASH_TOOL_NAME &&
|
|
!awaitAutomatedChecksBeforeDialog
|
|
) {
|
|
// UI indicator for "classifier running" — set here (not in
|
|
// toolExecution.ts) so commands that auto-allow via prefix rules
|
|
// don't flash the indicator for a split second before allow returns.
|
|
setClassifierChecking(ctx.toolUseID)
|
|
void executeAsyncClassifierCheck(
|
|
result.pendingClassifierCheck,
|
|
ctx.toolUseContext.abortController.signal,
|
|
ctx.toolUseContext.options.isNonInteractiveSession,
|
|
{
|
|
shouldContinue: () => !isResolved() && !userInteracted,
|
|
onComplete: () => {
|
|
clearClassifierChecking(ctx.toolUseID)
|
|
clearClassifierIndicator()
|
|
},
|
|
onAllow: decisionReason => {
|
|
if (!claim()) return
|
|
forgetPipePermission(
|
|
'Permission request was auto-approved before pipe response.',
|
|
)
|
|
if (bridgeCallbacks && bridgeRequestId) {
|
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
|
}
|
|
channelUnsubscribe?.()
|
|
clearClassifierChecking(ctx.toolUseID)
|
|
|
|
const matchedRule =
|
|
decisionReason.type === 'classifier'
|
|
? (decisionReason.reason.match(
|
|
/^Allowed by prompt rule: "(.+)"$/,
|
|
)?.[1] ?? decisionReason.reason)
|
|
: undefined
|
|
|
|
// Show auto-approved transition with dimmed options
|
|
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
|
ctx.updateQueueItem({
|
|
classifierCheckInProgress: false,
|
|
classifierAutoApproved: true,
|
|
classifierMatchedRule: matchedRule,
|
|
})
|
|
}
|
|
|
|
if (
|
|
feature('TRANSCRIPT_CLASSIFIER') &&
|
|
decisionReason.type === 'classifier'
|
|
) {
|
|
if (decisionReason.classifier === 'auto-mode') {
|
|
setYoloClassifierApproval(ctx.toolUseID, decisionReason.reason)
|
|
} else if (matchedRule) {
|
|
setClassifierApproval(ctx.toolUseID, matchedRule)
|
|
}
|
|
}
|
|
|
|
ctx.logDecision(
|
|
{ decision: 'accept', source: { type: 'classifier' } },
|
|
{ permissionPromptStartTimeMs },
|
|
)
|
|
resolveOnce(ctx.buildAllow(ctx.input, { decisionReason }))
|
|
|
|
// Keep checkmark visible, then remove dialog.
|
|
// 3s if terminal is focused (user can see it), 1s if not.
|
|
// User can dismiss early with Esc via onDismissCheckmark.
|
|
const signal = ctx.toolUseContext.abortController.signal
|
|
checkmarkAbortHandler = () => {
|
|
if (checkmarkTransitionTimer) {
|
|
clearTimeout(checkmarkTransitionTimer)
|
|
checkmarkTransitionTimer = undefined
|
|
// Sibling Bash error can fire this (StreamingToolExecutor
|
|
// cascades via siblingAbortController) — must drop the
|
|
// cosmetic ✓ dialog or it blocks the next queued item.
|
|
ctx.removeFromQueue()
|
|
}
|
|
}
|
|
const checkmarkMs = getTerminalFocused() ? 3000 : 1000
|
|
checkmarkTransitionTimer = setTimeout(() => {
|
|
checkmarkTransitionTimer = undefined
|
|
if (checkmarkAbortHandler) {
|
|
signal.removeEventListener('abort', checkmarkAbortHandler)
|
|
checkmarkAbortHandler = undefined
|
|
}
|
|
ctx.removeFromQueue()
|
|
}, checkmarkMs)
|
|
signal.addEventListener('abort', checkmarkAbortHandler, {
|
|
once: true,
|
|
})
|
|
},
|
|
},
|
|
).catch(error => {
|
|
// Log classifier API errors for debugging but don't propagate them as interruptions
|
|
// These errors can be network failures, rate limits, or model issues - not user cancellations
|
|
logForDebugging(`Async classifier check failed: ${errorMessage(error)}`, {
|
|
level: 'error',
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// --
|
|
|
|
export { handleInteractivePermission }
|
|
export type { InteractivePermissionParams }
|