feat: 正式启用 auto mode (#307)

* fix: 修复settings.json内存状态溢出的问题

* fix: 修复auto mode gate check未处理的promise rejection

在 bypassPermissionsKillswitch.ts 的 useKickOffCheckAndDisableAutoModeIfNeeded
中,void fire-and-forget 调用缺少 .catch() 处理,导致 verifyAutoModeGateAccess
失败时产生 unhandled promise rejection。同时移除 permissionSetup.ts 中冗余的
null check。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 开放 auto mode 和 bypass mode 给所有用户

通过 Shift+Tab 统一循环:default → acceptEdits → plan → auto → bypassPermissions → default

- 移除 USER_TYPE 分支判断,所有用户使用同一循环路径
- isBypassPermissionsModeAvailable 始终为 true
- isAutoModeAvailable 初始化直接为 true
- 移除 AutoModeOptInDialog 确认流程
- 简化 isAutoModeGateEnabled 仅保留快模式熔断器
- 简化 verifyAutoModeGateAccess 仅检查快模式
- 移除 GrowthBook/Statsig 远程门控
- bypass permissions killswitch 改为 no-op
- 新增 24 个测试覆盖循环逻辑和门控不变量

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 为sideQuery添加Langfuse追踪

sideQuery 绕过了 claude.ts 的主 API 路径,导致所有走 sideQuery 的调用
(auto mode classifier、permission explainer、session search 等)都没有
Langfuse 记录。现在为每次 sideQuery 调用创建独立 trace 并记录 LLM observation,
未配置 Langfuse 时全部 no-op。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: ACP availableModes 补齐 bypassPermissions 并修正测试 import 路径

- ACP agent availableModes 按条件包含 bypassPermissions(非 root/sandbox)
- 顺序对齐 REPL 循环:default → acceptEdits → plan → auto → bypassPermissions
- 新增 2 个测试验证 availableModes 包含 bypassPermissions 及模式切换
- 修正 getNextPermissionMode.test.ts 和 permissionSetup.test.ts 的 import 路径

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-20 10:20:27 +08:00
committed by GitHub
parent a67e2d0e97
commit 92f8a92fbb
18 changed files with 510 additions and 605 deletions

View File

@@ -151,16 +151,14 @@ import {
isOpus1mMergeEnabled,
modelDisplayString,
} from '../../utils/model/model.js'
import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'
import {
cyclePermissionMode,
getNextPermissionMode,
} from '../../utils/permissions/getNextPermissionMode.js'
import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'
import { getPlatform } from '../../utils/platform.js'
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
import { editPromptInEditor } from '../../utils/promptEditor.js'
import { hasAutoModeOptIn } from '../../utils/settings/settings.js'
// hasAutoModeOptIn removed — auto mode is available to all users
import { findBtwTriggerPositions } from '../../utils/sideQuestion.js'
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
import {
@@ -187,7 +185,7 @@ import {
findUltraplanTriggerPositions,
findUltrareviewTriggerPositions,
} from '../../utils/ultraplan/keyword.js'
import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js'
// AutoModeOptInDialog removed — auto mode is available to all users
import { BridgeDialog } from '../BridgeDialog.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import {
@@ -571,10 +569,6 @@ function PromptInput({
const [showHistoryPicker, setShowHistoryPicker] = useState(false)
const [showFastModePicker, setShowFastModePicker] = useState(false)
const [showThinkingToggle, setShowThinkingToggle] = useState(false)
const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false)
const [previousModeBeforeAuto, setPreviousModeBeforeAuto] =
useState<PermissionMode | null>(null)
const autoModeOptInTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Check if cursor is on the first line of input
const isCursorOnFirstLine = useMemo(() => {
@@ -1883,86 +1877,11 @@ function PromptInput({
// Compute the next mode without triggering side effects first
logForDebugging(
`[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`,
`[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode}`,
)
const nextMode = getNextPermissionMode(toolPermissionContext, teamContext)
// Check if user is entering auto mode for the first time. Gated on the
// persistent settings flag (hasAutoModeOptIn) rather than the broader
// hasAutoModeOptInAnySource so that --enable-auto-mode users still see
// the warning dialog once — the CLI flag should grant carousel access,
// not bypass the safety text.
let isEnteringAutoModeFirstTime = false
if (feature('TRANSCRIPT_CLASSIFIER')) {
isEnteringAutoModeFirstTime =
nextMode === 'auto' &&
toolPermissionContext.mode !== 'auto' &&
!hasAutoModeOptIn() &&
!viewingAgentTaskId // Only show for primary agent, not subagents
}
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (isEnteringAutoModeFirstTime) {
// Store previous mode so we can revert if user declines
setPreviousModeBeforeAuto(toolPermissionContext.mode)
// Only update the UI mode label — do NOT call transitionPermissionMode
// or cyclePermissionMode yet; we haven't confirmed with the user.
setAppState(prev => ({
...prev,
toolPermissionContext: {
...prev.toolPermissionContext,
mode: 'auto',
},
}))
setToolPermissionContext({
...toolPermissionContext,
mode: 'auto',
})
// Show opt-in dialog after 400ms debounce
if (autoModeOptInTimeoutRef.current) {
clearTimeout(autoModeOptInTimeoutRef.current)
}
autoModeOptInTimeoutRef.current = setTimeout(
(setShowAutoModeOptIn, autoModeOptInTimeoutRef) => {
setShowAutoModeOptIn(true)
autoModeOptInTimeoutRef.current = null
},
400,
setShowAutoModeOptIn,
autoModeOptInTimeoutRef,
)
if (helpOpen) {
setHelpOpen(false)
}
return
}
}
// Dismiss auto mode opt-in dialog if showing or pending (user is cycling away).
// Do NOT revert to previousModeBeforeAuto here — shift+tab means "advance the
// carousel", not "decline". Reverting causes a ping-pong loop: auto reverts to
// the prior mode, whose next mode is auto again, forever.
// The dialog's own decline button (handleAutoModeOptInDecline) handles revert.
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (showAutoModeOptIn || autoModeOptInTimeoutRef.current) {
if (showAutoModeOptIn) {
logEvent('tengu_auto_mode_opt_in_dialog_decline', {})
}
setShowAutoModeOptIn(false)
if (autoModeOptInTimeoutRef.current) {
clearTimeout(autoModeOptInTimeoutRef.current)
autoModeOptInTimeoutRef.current = null
}
setPreviousModeBeforeAuto(null)
// Fall through — mode is 'auto', cyclePermissionMode below goes to 'default'.
}
}
// Now that we know this is NOT the first-time auto mode path,
// call cyclePermissionMode to apply side effects (e.g. strip
// Call cyclePermissionMode to apply side effects (e.g. strip
// dangerous permissions, activate classifier)
const { context: preparedContext } = cyclePermissionMode(
toolPermissionContext,
@@ -2007,91 +1926,10 @@ function PromptInput({
}, [
toolPermissionContext,
teamContext,
viewingAgentTaskId,
viewedTeammate,
setAppState,
setToolPermissionContext,
helpOpen,
showAutoModeOptIn,
])
// Handler for auto mode opt-in dialog acceptance
const handleAutoModeOptInAccept = useCallback(() => {
if (feature('TRANSCRIPT_CLASSIFIER')) {
setShowAutoModeOptIn(false)
setPreviousModeBeforeAuto(null)
// Now that the user accepted, apply the full transition: activate the
// auto mode backend (classifier, beta headers) and strip dangerous
// permissions (e.g. Bash(*) always-allow rules).
const strippedContext = transitionPermissionMode(
previousModeBeforeAuto ?? toolPermissionContext.mode,
'auto',
toolPermissionContext,
)
setAppState(prev => ({
...prev,
toolPermissionContext: {
...strippedContext,
mode: 'auto',
},
}))
setToolPermissionContext({
...strippedContext,
mode: 'auto',
})
// Close help tips if they're open when auto mode is enabled
if (helpOpen) {
setHelpOpen(false)
}
}
}, [
helpOpen,
setHelpOpen,
previousModeBeforeAuto,
toolPermissionContext,
setAppState,
setToolPermissionContext,
])
// Handler for auto mode opt-in dialog decline
const handleAutoModeOptInDecline = useCallback(() => {
if (feature('TRANSCRIPT_CLASSIFIER')) {
logForDebugging(
`[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`,
)
setShowAutoModeOptIn(false)
if (autoModeOptInTimeoutRef.current) {
clearTimeout(autoModeOptInTimeoutRef.current)
autoModeOptInTimeoutRef.current = null
}
// Revert to previous mode and remove auto from the carousel
// for the rest of this session
if (previousModeBeforeAuto) {
setAutoModeActive(false)
setAppState(prev => ({
...prev,
toolPermissionContext: {
...prev.toolPermissionContext,
mode: previousModeBeforeAuto,
isAutoModeAvailable: false,
},
}))
setToolPermissionContext({
...toolPermissionContext,
mode: previousModeBeforeAuto,
isAutoModeAvailable: false,
})
setPreviousModeBeforeAuto(null)
}
}
}, [
previousModeBeforeAuto,
toolPermissionContext,
setAppState,
setToolPermissionContext,
])
// Handler for chat:imagePaste - paste image from clipboard
@@ -2758,20 +2596,7 @@ function PromptInput({
// Portal dialog to DialogOverlay in fullscreen so it escapes the bottom
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
// Must be called before early returns below to satisfy rules-of-hooks.
// Memoized so the portal useEffect doesn't churn on every PromptInput render.
const autoModeOptInDialog = useMemo(
() =>
feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? (
<AutoModeOptInDialog
onAccept={handleAutoModeOptInAccept}
onDecline={handleAutoModeOptInDecline}
/>
) : null,
[showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline],
)
useSetPromptOverlayDialog(
isFullscreenEnvEnabled() ? autoModeOptInDialog : null,
)
useSetPromptOverlayDialog(null)
if (showBashesDialog) {
return (
@@ -3077,7 +2902,6 @@ function PromptInput({
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
}
/>
{isFullscreenEnvEnabled() ? null : autoModeOptInDialog}
{isFullscreenEnvEnabled() ? (
// position=absolute takes zero layout height so the spinner
// doesn't shift when a notification appears/disappears. Yoga
@@ -3098,7 +2922,7 @@ function PromptInput({
<Box
position="absolute"
marginTop={briefOwnsGap ? -2 : -1}
height={suggestions.length === 0 && !showAutoModeOptIn ? 1 : 0}
height={suggestions.length === 0 ? 1 : 0}
width="100%"
paddingLeft={2}
paddingRight={1}