Files
claude-code/src/hooks/toolPermission/handlers/interactiveHandler.ts
claude-code-best 494eab7204 feat: 接入内建 weixin channel(同 #301 重构版本) (#303)
* 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>
2026-04-19 21:33:27 +08:00

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 }