Compare commits

...

4 Commits

Author SHA1 Message Date
claude-code-best
e4ce08fe39 Fixture/langfuse record auto mode data error (#308)
* fix: 修复状态栏 context 计数器在 loading 时闪现为 0 的问题

第三方 API(如智谱)在 message_start 中可能不返回完整 usage 数据,
导致 getCurrentUsage 返回全零 usage 对象,使 ctx 显示为 0%。

双重保护:
- getCurrentUsage: 跳过全零 usage,继续往前找有真实数据的 message
- calculateContextPercentages: totalInputTokens 为 0 时返回 null

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

* fix: 外部化 ESM 包使用 createRequire 替代裸 require

color-diff-napi、image-processor-napi、audio-capture-napi 声明
"type": "module" 但使用裸 require(),Node.js ESM 中 require
不可用。改用 createRequire(import.meta.url) 或顶层 import。

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

* fix: getDefaultSonnetModel 优先使用用户配置的模型,修复第三方 provider 模型不存在错误

当用户通过 ANTHROPIC_MODEL 或 settings 配置了自定义 provider 支持的模型时,
getDefaultSonnetModel/Haiku/Opus 现在会优先使用该配置,而非硬编码 Anthropic 官方模型 ID。
同时改进 Langfuse 可观测性:sideQuery 失败时记录错误信息到 span,
optional 模式下标记 WARNING 而非 ERROR。

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

* fix: 将 auto_mode classifier 的 side-query span 绑定到父 trace

classifyYoloAction 及 classifyYoloActionXml 接收 parentSpan 参数,
透传给 sideQuery 调用,使 auto_mode 的 side-query span 嵌套在主 agent trace 下。

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

* fix: 穷鬼模式下跳过 memdir_relevance side-query

Poor mode 启用时不执行 findRelevantMemories 的预取调用,
避免额外的 API token 消耗。

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

* chore: 添加 test:all 脚本用于完成任务后的全量检查

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

* fix: Vite 构建补齐缺失的 feature flags,修复 auto mode 不可见

Vite 构建插件的 DEFAULT_BUILD_FEATURES 缺少 BUDDY、TRANSCRIPT_CLASSIFIER、
BRIDGE_MODE、ACP、BG_SESSIONS、TEMPLATES,导致 feature('TRANSCRIPT_CLASSIFIER')
被替换为 false,auto mode 从 Shift+Tab 循环中消失。与 build.ts 对齐。

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

* fix: 统一 feature flags 到 defines.ts,修复 Vite 构建缺失 auto mode

将 DEFAULT_BUILD_FEATURES 列表从 build.ts、dev.ts、vite-plugin-feature-flags.ts
三处内联定义统一到 scripts/defines.ts 单一导出。之前的 Vite 插件缺少
TRANSCRIPT_CLASSIFIER 等 feature flag,导致 auto mode 在 Vite 构建中不可见。

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 13:30:05 +08:00
claude-code-best
92f8a92fbb 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>
2026-04-20 10:20:27 +08:00
claude-code-best
a67e2d0e97 docs: 更新 npm 安装 2026-04-19 22:00:48 +08:00
claude-code-best
8c629858ab chore: 1.6.0 2026-04-19 21:37:35 +08:00
37 changed files with 736 additions and 733 deletions

View File

@@ -58,6 +58,9 @@ bun run health
# Check unused exports # Check unused exports
bun run check:unused bun run check:unused
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck bun run typecheck
# Remote Control Server # Remote Control Server

View File

@@ -41,8 +41,12 @@
不用克隆仓库, 从 NPM 下载后, 直接使用 不用克隆仓库, 从 NPM 下载后, 直接使用
```sh ```sh
bun i -g claude-code-best npm i -g claude-code-best
bun pm -g trust claude-code-best
# bun 安装比较多问题, 推荐 npm 装
# bun i -g claude-code-best
# bun pm -g trust claude-code-best
ccb # 以 nodejs 打开 claude code ccb # 以 nodejs 打开 claude code
ccb-bun # 以 bun 形态打开 ccb-bun # 以 bun 形态打开
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制 CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制

View File

@@ -1,6 +1,7 @@
import { readdir, readFile, writeFile, cp } from 'fs/promises' import { readdir, readFile, writeFile, cp } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { getMacroDefines } from './scripts/defines.ts' import { getMacroDefines } from './scripts/defines.ts'
import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts'
const outdir = 'dist' const outdir = 'dist'
@@ -8,48 +9,6 @@ const outdir = 'dist'
const { rmSync } = await import('fs') const { rmSync } = await import('fs')
rmSync(outdir, { recursive: true, force: true }) rmSync(outdir, { recursive: true, force: true })
// Default features that match the official CLI build.
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP',
'VOICE_MODE',
'SHOT_STATS',
'PROMPT_CACHE_BREAK_DETECTION',
'TOKEN_BUDGET',
// P0: local features
'AGENT_TRIGGERS',
'ULTRATHINK',
'BUILTIN_EXPLORE_PLAN_AGENTS',
'LODESTONE',
// P1: API-dependent features
'EXTRACT_MEMORIES',
'VERIFICATION_AGENT',
'KAIROS_BRIEF',
'AWAY_SUMMARY',
'ULTRAPLAN',
// P2: daemon + remote control server
'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features
'WORKFLOW_SCRIPTS',
'HISTORY_SNIP',
'CONTEXT_COLLAPSE',
'MONITOR_TOOL',
'FORK_SUBAGENT',
// 'UDS_INBOX',
'KAIROS',
'COORDINATOR_MODE',
'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR',
]
// Collect FEATURE_* env vars → Bun.build features // Collect FEATURE_* env vars → Bun.build features
const envFeatures = Object.keys(process.env) const envFeatures = Object.keys(process.env)
.filter(k => k.startsWith('FEATURE_')) .filter(k => k.startsWith('FEATURE_'))

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "1.5.0", "version": "1.6.0",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",
@@ -58,6 +58,7 @@
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs", "postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev", "docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test:all": "bun run typecheck && bun test",
"rcs": "bun run scripts/rcs.ts" "rcs": "bun run scripts/rcs.ts"
}, },
"dependencies": { "dependencies": {

View File

@@ -1,3 +1,9 @@
import { createRequire } from 'node:module'
// createRequire works in both Bun and Node.js ESM contexts.
// Needed because this package is "type": "module" but uses require() for
// loading native .node addons — bare require is not available in Node.js ESM.
const nodeRequire = createRequire(import.meta.url)
type AudioCaptureNapi = { type AudioCaptureNapi = {
startRecording( startRecording(
@@ -41,7 +47,7 @@ function loadModule(): AudioCaptureNapi | null {
if (process.env.AUDIO_CAPTURE_NODE_PATH) { if (process.env.AUDIO_CAPTURE_NODE_PATH) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
cachedModule = require( cachedModule = nodeRequire(
process.env.AUDIO_CAPTURE_NODE_PATH, process.env.AUDIO_CAPTURE_NODE_PATH,
) as AudioCaptureNapi ) as AudioCaptureNapi
return cachedModule return cachedModule
@@ -63,7 +69,7 @@ function loadModule(): AudioCaptureNapi | null {
for (const p of fallbacks) { for (const p of fallbacks) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
cachedModule = require(p) as AudioCaptureNapi cachedModule = nodeRequire(p) as AudioCaptureNapi
return cachedModule return cachedModule
} catch { } catch {
// try next // try next

View File

@@ -17,10 +17,16 @@
* getSyntaxTheme always returns the default for the given Claude theme. * getSyntaxTheme always returns the default for the given Claude theme.
*/ */
import { createRequire } from 'node:module'
import { diffArrays } from 'diff' import { diffArrays } from 'diff'
import type * as hljsNamespace from 'highlight.js' import type * as hljsNamespace from 'highlight.js'
import { basename, extname } from 'path' import { basename, extname } from 'path'
// createRequire works in both Bun and Node.js ESM contexts.
// Needed because this package is "type": "module" but uses require() for
// lazy loading — bare require is not available in Node.js ESM.
const nodeRequire = createRequire(import.meta.url)
// Lazy: defers loading highlight.js until first render. The full bundle // Lazy: defers loading highlight.js until first render. The full bundle
// registers 190+ language grammars at require time (~50MB, 100-200ms on // registers 190+ language grammars at require time (~50MB, 100-200ms on
// macOS, several× that on Windows). With a top-level import, any caller // macOS, several× that on Windows). With a top-level import, any caller
@@ -34,8 +40,7 @@ type HLJSApi = typeof hljsNamespace.default
let cachedHljs: HLJSApi | null = null let cachedHljs: HLJSApi | null = null
function hljs(): HLJSApi { function hljs(): HLJSApi {
if (cachedHljs) return cachedHljs if (cachedHljs) return cachedHljs
// eslint-disable-next-line @typescript-eslint/no-require-imports const mod = nodeRequire('highlight.js')
const mod = require('highlight.js')
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it // highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
// in .default; under node CJS the module IS the API. Check at runtime. // in .default; under node CJS the module IS the API. Check at runtime.
cachedHljs = 'default' in mod && mod.default ? mod.default : mod cachedHljs = 'default' in mod && mod.default ? mod.default : mod

View File

@@ -1,3 +1,4 @@
import { readFileSync, unlinkSync } from 'node:fs'
import sharpModule from 'sharp' import sharpModule from 'sharp'
export const sharp = sharpModule export const sharp = sharpModule
@@ -62,13 +63,11 @@ return "${tmpPath}"
} }
const file = Bun.file(tmpPath) const file = Bun.file(tmpPath)
// Use synchronous read via Node compat const buffer: Buffer = readFileSync(tmpPath)
const fs = require('fs')
const buffer: Buffer = fs.readFileSync(tmpPath)
// Clean up temp file // Clean up temp file
try { try {
fs.unlinkSync(tmpPath) unlinkSync(tmpPath)
} catch { } catch {
// ignore cleanup errors // ignore cleanup errors
} }

View File

@@ -16,3 +16,52 @@ export function getMacroDefines(): Record<string, string> {
"MACRO.VERSION_CHANGELOG": JSON.stringify(""), "MACRO.VERSION_CHANGELOG": JSON.stringify(""),
}; };
} }
/**
* Default feature flags enabled in both Bun.build and Vite builds.
* Additional features can be enabled via FEATURE_<NAME>=1 env vars.
*
* Used by:
* - build.ts (Bun.build)
* - scripts/vite-plugin-feature-flags.ts (Vite/Rollup)
* - scripts/dev.ts (bun run dev)
*/
export const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP',
'VOICE_MODE',
'SHOT_STATS',
'PROMPT_CACHE_BREAK_DETECTION',
'TOKEN_BUDGET',
// P0: local features
'AGENT_TRIGGERS',
'ULTRATHINK',
'BUILTIN_EXPLORE_PLAN_AGENTS',
'LODESTONE',
// P1: API-dependent features
'EXTRACT_MEMORIES',
'VERIFICATION_AGENT',
'KAIROS_BRIEF',
'AWAY_SUMMARY',
'ULTRAPLAN',
// P2: daemon + remote control server
'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features
'WORKFLOW_SCRIPTS',
'HISTORY_SNIP',
'CONTEXT_COLLAPSE',
'MONITOR_TOOL',
'FORK_SUBAGENT',
// 'UDS_INBOX',
'KAIROS',
'COORDINATOR_MODE',
'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR',
] as const;

View File

@@ -6,7 +6,7 @@
*/ */
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { getMacroDefines } from "./defines.ts"; import { getMacroDefines, DEFAULT_BUILD_FEATURES } from "./defines.ts";
// Resolve project root from this script's location // Resolve project root from this script's location
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -22,39 +22,7 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
]); ]);
// Bun --feature flags: enable feature() gates at runtime. // Bun --feature flags: enable feature() gates at runtime.
// Default features enabled in dev mode. // Uses the shared DEFAULT_BUILD_FEATURES list from defines.ts.
const DEFAULT_FEATURES = [
"BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE",
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET",
// P0: local features
"AGENT_TRIGGERS",
"ULTRATHINK",
"BUILTIN_EXPLORE_PLAN_AGENTS",
"LODESTONE",
// P1: API-dependent features
"EXTRACT_MEMORIES", "VERIFICATION_AGENT",
"KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN",
// P2: daemon + remote control server
"DAEMON",
// ACP (Agent Client Protocol) agent mode
"ACP",
// PR-package restored features
"WORKFLOW_SCRIPTS",
"HISTORY_SNIP",
"CONTEXT_COLLAPSE",
"MONITOR_TOOL",
"FORK_SUBAGENT",
"UDS_INBOX",
"KAIROS",
"COORDINATOR_MODE",
"LAN_PIPES",
"BG_SESSIONS",
"TEMPLATES",
// "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
"POOR",
];
// Any env var matching FEATURE_<NAME>=1 will also enable that feature. // Any env var matching FEATURE_<NAME>=1 will also enable that feature.
// e.g. FEATURE_PROACTIVE=1 bun run dev // e.g. FEATURE_PROACTIVE=1 bun run dev
@@ -62,7 +30,7 @@ const envFeatures = Object.entries(process.env)
.filter(([k]) => k.startsWith("FEATURE_")) .filter(([k]) => k.startsWith("FEATURE_"))
.map(([k]) => k.replace("FEATURE_", "")); .map(([k]) => k.replace("FEATURE_", ""));
const allFeatures = [...new Set([...DEFAULT_FEATURES, ...envFeatures])]; const allFeatures = [...new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures])];
const featureArgs = allFeatures.flatMap((name) => ["--feature", name]); const featureArgs = allFeatures.flatMap((name) => ["--feature", name]);
// If BUN_INSPECT is set, pass --inspect-wait to the child process // If BUN_INSPECT is set, pass --inspect-wait to the child process

View File

@@ -1,41 +1,5 @@
import type { Plugin } from "rollup"; import type { Plugin } from "rollup";
import { DEFAULT_BUILD_FEATURES } from "./defines.ts";
/**
* Default features that match the official CLI build.
* Additional features can be enabled via FEATURE_<NAME>=1 env vars.
*/
const DEFAULT_BUILD_FEATURES = [
"AGENT_TRIGGERS_REMOTE",
"CHICAGO_MCP",
"VOICE_MODE",
"SHOT_STATS",
"PROMPT_CACHE_BREAK_DETECTION",
"TOKEN_BUDGET",
// P0: local features
"AGENT_TRIGGERS",
"ULTRATHINK",
"BUILTIN_EXPLORE_PLAN_AGENTS",
"LODESTONE",
// P1: API-dependent features
"EXTRACT_MEMORIES",
"VERIFICATION_AGENT",
"KAIROS_BRIEF",
"AWAY_SUMMARY",
"ULTRAPLAN",
// P2: daemon + remote control server
"DAEMON",
// PR-package restored features
"WORKFLOW_SCRIPTS",
"HISTORY_SNIP",
"CONTEXT_COLLAPSE",
"MONITOR_TOOL",
"FORK_SUBAGENT",
"KAIROS",
"COORDINATOR_MODE",
"LAN_PIPES",
// P3: poor mode
"POOR",
];
/** /**
* Collect enabled feature flags from defaults + env vars. * Collect enabled feature flags from defaults + env vars.

View File

@@ -146,7 +146,7 @@ export const getEmptyToolPermissionContext: () => ToolPermissionContext =
alwaysAllowRules: {}, alwaysAllowRules: {},
alwaysDenyRules: {}, alwaysDenyRules: {},
alwaysAskRules: {}, alwaysAskRules: {},
isBypassPermissionsModeAvailable: false, isBypassPermissionsModeAvailable: true,
}) })
export type CompactProgressEvent = export type CompactProgressEvent =

View File

@@ -166,9 +166,9 @@ describe('getEmptyToolPermissionContext', () => {
expect(ctx.alwaysAskRules).toEqual({}) expect(ctx.alwaysAskRules).toEqual({})
}) })
test('returns isBypassPermissionsModeAvailable as false', () => { test('returns isBypassPermissionsModeAvailable as true', () => {
const ctx = getEmptyToolPermissionContext() const ctx = getEmptyToolPermissionContext()
expect(ctx.isBypassPermissionsModeAvailable).toBe(false) expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
}) })
}) })

View File

@@ -18,9 +18,7 @@ import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { stripSignatureBlocks } from '../../utils/messages.js' import { stripSignatureBlocks } from '../../utils/messages.js'
import { import {
checkAndDisableAutoModeIfNeeded, checkAndDisableAutoModeIfNeeded,
checkAndDisableBypassPermissionsIfNeeded,
resetAutoModeGateCheck, resetAutoModeGateCheck,
resetBypassPermissionsCheck,
} from '../../utils/permissions/bypassPermissionsKillswitch.js' } from '../../utils/permissions/bypassPermissionsKillswitch.js'
import { resetUserCache } from '../../utils/user.js' import { resetUserCache } from '../../utils/user.js'
@@ -54,20 +52,13 @@ export async function call(
// Enroll as a trusted device for Remote Control (10-min fresh-session window) // Enroll as a trusted device for Remote Control (10-min fresh-session window)
void enrollTrustedDevice() void enrollTrustedDevice()
// Reset killswitch gate checks and re-run with new org // Reset killswitch gate checks and re-run with new org
resetBypassPermissionsCheck() resetAutoModeGateCheck()
const appState = context.getAppState() const appState = context.getAppState()
void checkAndDisableBypassPermissionsIfNeeded( void checkAndDisableAutoModeIfNeeded(
appState.toolPermissionContext, appState.toolPermissionContext,
context.setAppState, context.setAppState,
appState.fastMode,
) )
if (feature('TRANSCRIPT_CLASSIFIER')) {
resetAutoModeGateCheck()
void checkAndDisableAutoModeIfNeeded(
appState.toolPermissionContext,
context.setAppState,
appState.fastMode,
)
}
// Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers) // Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers)
context.setAppState(prev => ({ context.setAppState(prev => ({
...prev, ...prev,

View File

@@ -151,16 +151,14 @@ import {
isOpus1mMergeEnabled, isOpus1mMergeEnabled,
modelDisplayString, modelDisplayString,
} from '../../utils/model/model.js' } from '../../utils/model/model.js'
import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'
import { import {
cyclePermissionMode, cyclePermissionMode,
getNextPermissionMode, getNextPermissionMode,
} from '../../utils/permissions/getNextPermissionMode.js' } from '../../utils/permissions/getNextPermissionMode.js'
import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'
import { getPlatform } from '../../utils/platform.js' import { getPlatform } from '../../utils/platform.js'
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
import { editPromptInEditor } from '../../utils/promptEditor.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 { findBtwTriggerPositions } from '../../utils/sideQuestion.js'
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js' import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
import { import {
@@ -187,7 +185,7 @@ import {
findUltraplanTriggerPositions, findUltraplanTriggerPositions,
findUltrareviewTriggerPositions, findUltrareviewTriggerPositions,
} from '../../utils/ultraplan/keyword.js' } 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 { BridgeDialog } from '../BridgeDialog.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import { import {
@@ -571,10 +569,6 @@ function PromptInput({
const [showHistoryPicker, setShowHistoryPicker] = useState(false) const [showHistoryPicker, setShowHistoryPicker] = useState(false)
const [showFastModePicker, setShowFastModePicker] = useState(false) const [showFastModePicker, setShowFastModePicker] = useState(false)
const [showThinkingToggle, setShowThinkingToggle] = 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 // Check if cursor is on the first line of input
const isCursorOnFirstLine = useMemo(() => { const isCursorOnFirstLine = useMemo(() => {
@@ -1883,86 +1877,11 @@ function PromptInput({
// Compute the next mode without triggering side effects first // Compute the next mode without triggering side effects first
logForDebugging( 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) const nextMode = getNextPermissionMode(toolPermissionContext, teamContext)
// Check if user is entering auto mode for the first time. Gated on the // Call cyclePermissionMode to apply side effects (e.g. strip
// 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
// dangerous permissions, activate classifier) // dangerous permissions, activate classifier)
const { context: preparedContext } = cyclePermissionMode( const { context: preparedContext } = cyclePermissionMode(
toolPermissionContext, toolPermissionContext,
@@ -2007,91 +1926,10 @@ function PromptInput({
}, [ }, [
toolPermissionContext, toolPermissionContext,
teamContext, teamContext,
viewingAgentTaskId,
viewedTeammate, viewedTeammate,
setAppState, setAppState,
setToolPermissionContext, setToolPermissionContext,
helpOpen, 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 // 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 // Portal dialog to DialogOverlay in fullscreen so it escapes the bottom
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay). // slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
// Must be called before early returns below to satisfy rules-of-hooks. // Must be called before early returns below to satisfy rules-of-hooks.
// Memoized so the portal useEffect doesn't churn on every PromptInput render. useSetPromptOverlayDialog(null)
const autoModeOptInDialog = useMemo(
() =>
feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? (
<AutoModeOptInDialog
onAccept={handleAutoModeOptInAccept}
onDecline={handleAutoModeOptInDecline}
/>
) : null,
[showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline],
)
useSetPromptOverlayDialog(
isFullscreenEnvEnabled() ? autoModeOptInDialog : null,
)
if (showBashesDialog) { if (showBashesDialog) {
return ( return (
@@ -3077,7 +2902,6 @@ function PromptInput({
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
} }
/> />
{isFullscreenEnvEnabled() ? null : autoModeOptInDialog}
{isFullscreenEnvEnabled() ? ( {isFullscreenEnvEnabled() ? (
// position=absolute takes zero layout height so the spinner // position=absolute takes zero layout height so the spinner
// doesn't shift when a notification appears/disappears. Yoga // doesn't shift when a notification appears/disappears. Yoga
@@ -3098,7 +2922,7 @@ function PromptInput({
<Box <Box
position="absolute" position="absolute"
marginTop={briefOwnsGap ? -2 : -1} marginTop={briefOwnsGap ? -2 : -1}
height={suggestions.length === 0 && !showAutoModeOptIn ? 1 : 0} height={suggestions.length === 0 ? 1 : 0}
width="100%" width="100%"
paddingLeft={2} paddingLeft={2}
paddingRight={1} paddingRight={1}

View File

@@ -52,7 +52,6 @@ import type { PermissionMode } from './utils/permissions/PermissionMode.js'
import { getBaseRenderOptions } from './utils/renderOptions.js' import { getBaseRenderOptions } from './utils/renderOptions.js'
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js' import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'
import { import {
hasAutoModeOptIn,
hasSkipDangerousModePermissionPrompt, hasSkipDangerousModePermissionPrompt,
} from './utils/settings/settings.js' } from './utils/settings/settings.js'
@@ -309,25 +308,6 @@ export async function showSetupScreens(
)) ))
} }
if (feature('TRANSCRIPT_CLASSIFIER')) {
// Only show the opt-in dialog if auto mode actually resolved — if the
// gate denied it (org not allowlisted, settings disabled), showing
// consent for an unavailable feature is pointless. The
// verifyAutoModeGateAccess notification will explain why instead.
if (permissionMode === 'auto' && !hasAutoModeOptIn()) {
const { AutoModeOptInDialog } = await import(
'./components/AutoModeOptInDialog.js'
)
await showSetupDialog(root, done => (
<AutoModeOptInDialog
onAccept={done}
onDecline={() => gracefulShutdownSync(1)}
declineExits
/>
))
}
}
// --dangerously-load-development-channels confirmation. On accept, append // --dangerously-load-development-channels confirmation. On accept, append
// 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

View File

@@ -242,7 +242,6 @@ import {
import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js"; import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js";
import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js"; import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js";
import { import {
checkAndDisableBypassPermissions,
getAutoModeEnabledStateIfCached, getAutoModeEnabledStateIfCached,
initializeToolPermissionContext, initializeToolPermissionContext,
initialPermissionModeFromCLI, initialPermissionModeFromCLI,
@@ -3910,19 +3909,7 @@ async function run(): Promise<CommanderCommand> {
onChangeAppState, onChangeAppState,
); );
// Check if bypassPermissions should be disabled based on Statsig gate
// This runs in parallel to the code below, to avoid blocking the main loop.
if (
toolPermissionContext.mode === "bypassPermissions" ||
allowDangerouslySkipPermissions
) {
void checkAndDisableBypassPermissions(
toolPermissionContext,
);
}
// Async check of auto mode gate — corrects state and disables auto if needed. // Async check of auto mode gate — corrects state and disables auto if needed.
// Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too.
if (feature("TRANSCRIPT_CLASSIFIER")) { if (feature("TRANSCRIPT_CLASSIFIER")) {
void verifyAutoModeGateAccess( void verifyAutoModeGateAccess(
toolPermissionContext, toolPermissionContext,

View File

@@ -3,6 +3,7 @@ import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js' import { errorMessage } from '../utils/errors.js'
import { getDefaultSonnetModel } from '../utils/model/model.js' import { getDefaultSonnetModel } from '../utils/model/model.js'
import { sideQuery } from '../utils/sideQuery.js' import { sideQuery } from '../utils/sideQuery.js'
import type { LangfuseSpan } from '../services/langfuse/index.js'
import { jsonParse } from '../utils/slowOperations.js' import { jsonParse } from '../utils/slowOperations.js'
import { import {
formatMemoryManifest, formatMemoryManifest,
@@ -42,6 +43,7 @@ export async function findRelevantMemories(
signal: AbortSignal, signal: AbortSignal,
recentTools: readonly string[] = [], recentTools: readonly string[] = [],
alreadySurfaced: ReadonlySet<string> = new Set(), alreadySurfaced: ReadonlySet<string> = new Set(),
parentSpan?: LangfuseSpan | null,
): Promise<RelevantMemory[]> { ): Promise<RelevantMemory[]> {
const memories = (await scanMemoryFiles(memoryDir, signal)).filter( const memories = (await scanMemoryFiles(memoryDir, signal)).filter(
m => !alreadySurfaced.has(m.filePath), m => !alreadySurfaced.has(m.filePath),
@@ -55,6 +57,7 @@ export async function findRelevantMemories(
memories, memories,
signal, signal,
recentTools, recentTools,
parentSpan,
) )
const byFilename = new Map(memories.map(m => [m.filename, m])) const byFilename = new Map(memories.map(m => [m.filename, m]))
const selected = selectedFilenames const selected = selectedFilenames
@@ -79,6 +82,7 @@ async function selectRelevantMemories(
memories: MemoryHeader[], memories: MemoryHeader[],
signal: AbortSignal, signal: AbortSignal,
recentTools: readonly string[], recentTools: readonly string[],
parentSpan?: LangfuseSpan | null,
): Promise<string[]> { ): Promise<string[]> {
const validFilenames = new Set(memories.map(m => m.filename)) const validFilenames = new Set(memories.map(m => m.filename))
@@ -119,6 +123,8 @@ async function selectRelevantMemories(
}, },
signal, signal,
querySource: 'memdir_relevance', querySource: 'memdir_relevance',
optional: true,
parentSpan,
}) })
const textBlock = result.content.find(block => block.type === 'text') const textBlock = result.content.find(block => block.type === 'text')

View File

@@ -422,9 +422,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
import type { Theme } from 'src/utils/theme.js'; import type { Theme } from 'src/utils/theme.js';
import { import {
checkAndDisableBypassPermissionsIfNeeded,
checkAndDisableAutoModeIfNeeded, checkAndDisableAutoModeIfNeeded,
useKickOffCheckAndDisableBypassPermissionsIfNeeded,
useKickOffCheckAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded,
} from 'src/utils/permissions/bypassPermissionsKillswitch.js'; } from 'src/utils/permissions/bypassPermissionsKillswitch.js';
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js';
@@ -434,7 +432,6 @@ import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPerm
import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js'; import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js';
import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js'; import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js';
import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js'; import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js';
import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js';
import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js'; import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js';
import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js'; import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js';
import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js'; import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js';
@@ -948,7 +945,6 @@ export function REPL({
[toolPermissionContext, proactiveActive, isBriefOnly], [toolPermissionContext, proactiveActive, isBriefOnly],
); );
useKickOffCheckAndDisableBypassPermissionsIfNeeded();
useKickOffCheckAndDisableAutoModeIfNeeded(); useKickOffCheckAndDisableAutoModeIfNeeded();
const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>( const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>(
@@ -1006,7 +1002,6 @@ export function REPL({
useCanSwitchToExistingSubscription(); useCanSwitchToExistingSubscription();
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus }); useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus });
useMcpConnectivityStatus({ mcpClients }); useMcpConnectivityStatus({ mcpClients });
useAutoModeUnavailableNotification();
usePluginInstallationStatus(); usePluginInstallationStatus();
usePluginAutoupdateNotification(); usePluginAutoupdateNotification();
useSettingsErrors(); useSettingsErrors();
@@ -3314,8 +3309,8 @@ export function REPL({
queryCheckpoint('query_context_loading_start'); queryCheckpoint('query_context_loading_start');
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
// IMPORTANT: do this after setMessages() above, to avoid UI jank // IMPORTANT: do this after setMessages() above, to avoid UI jank
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), undefined,
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in // Fast-mode circuit breaker check
feature('TRANSCRIPT_CLASSIFIER') feature('TRANSCRIPT_CLASSIFIER')
? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode)
: undefined, : undefined,

View File

@@ -42,7 +42,7 @@ const mockGetDefaultAppState = mock(() => ({
alwaysAllowRules: { user: [], project: [], local: [] }, alwaysAllowRules: { user: [], project: [], local: [] },
alwaysDenyRules: { user: [], project: [], local: [] }, alwaysDenyRules: { user: [], project: [], local: [] },
alwaysAskRules: { user: [], project: [], local: [] }, alwaysAskRules: { user: [], project: [], local: [] },
isBypassPermissionsModeAvailable: false, isBypassPermissionsModeAvailable: true,
}, },
fastMode: false, fastMode: false,
settings: {}, settings: {},
@@ -627,6 +627,23 @@ describe('AcpAgent', () => {
agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any), agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any),
).rejects.toThrow('Session not found') ).rejects.toThrow('Session not found')
}) })
test('availableModes includes bypassPermissions when not root', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
expect(modeIds).toContain('bypassPermissions')
})
test('can switch to bypassPermissions mode', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
await agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any)
const session = agent.sessions.get(sessionId)
expect(session?.modes.currentModeId).toBe('bypassPermissions')
expect(session?.appState.toolPermissionContext.mode).toBe('bypassPermissions')
})
}) })
describe('setSessionConfigOption', () => { describe('setSessionConfigOption', () => {

View File

@@ -519,12 +519,15 @@ export class AcpAgent implements Agent {
const queryEngine = new QueryEngine(engineConfig) const queryEngine = new QueryEngine(engineConfig)
// Build modes // Build modes — bypassPermissions only available when not running as root (or in sandbox)
const availableModes = [ const availableModes = [
{ id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' },
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' }, { id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' }, { id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
{ id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' }, { id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' },
{ id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' },
...(isBypassAvailable
? [{ id: 'bypassPermissions' as const, name: 'Bypass Permissions', description: 'Skip all permission checks' }]
: []),
{ id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" }, { id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" },
] ]

View File

@@ -1,4 +1,4 @@
export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js' export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js'
export { createTrace, createSubagentTrace, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js' export { createTrace, createSubagentTrace, createChildSpan, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js'
export type { LangfuseSpan } from './tracing.js' export type { LangfuseSpan } from './tracing.js'
export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js' export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js'

View File

@@ -282,6 +282,60 @@ export function createSubagentTrace(params: {
} }
} }
/**
* Create a child span under a parent trace — used for side queries
* that should be nested under the main agent trace in Langfuse.
*/
export function createChildSpan(
parentSpan: LangfuseSpan | null,
params: {
name: string
sessionId: string
model: string
provider: string
input?: unknown
querySource?: string
username?: string
},
): LangfuseSpan | null {
if (!parentSpan || !isLangfuseEnabled()) return null
try {
const span = startObservation(
params.name,
{
input: params.input,
metadata: {
provider: params.provider,
model: params.model,
querySource: params.querySource,
},
},
{
asType: 'span',
parentSpanContext: parentSpan.otelSpan.spanContext(),
},
) as LangfuseSpan
// Propagate session ID and user ID from parent
const parent = parentSpan as unknown as RootTrace
const sessionId = parent._sessionId ?? params.sessionId
if (sessionId) {
span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
;(span as unknown as RootTrace)._sessionId = sessionId
}
const userId = parent._userId ?? resolveLangfuseUserId(params.username)
if (userId) {
span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
;(span as unknown as RootTrace)._userId = userId
}
logForDebugging(`[langfuse] Child span created: ${span.id} (parent=${parentSpan.id})`)
return span
} catch (e) {
logForDebugging(`[langfuse] createChildSpan failed: ${e}`, { level: 'error' })
return null
}
}
export function endTrace( export function endTrace(
rootSpan: LangfuseSpan | null, rootSpan: LangfuseSpan | null,
output?: unknown, output?: unknown,

View File

@@ -109,7 +109,6 @@ const externalTips: Tip[] = [
`Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`, `Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`,
cooldownSessions: 5, cooldownSessions: 5,
isRelevant: async () => { isRelevant: async () => {
if (process.env.USER_TYPE === 'ant') return false
const config = getGlobalConfig() const config = getGlobalConfig()
// Show to users who haven't used plan mode recently (7+ days) // Show to users who haven't used plan mode recently (7+ days)
const daysSinceLastUse = config.lastPlanModeUse const daysSinceLastUse = config.lastPlanModeUse
@@ -401,9 +400,7 @@ const externalTips: Tip[] = [
{ {
id: 'shift-tab', id: 'shift-tab',
content: async () => content: async () =>
process.env.USER_TYPE === 'ant' `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default, accept edits, plan, auto, and bypass modes`,
? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode`
: `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`,
cooldownSessions: 10, cooldownSessions: 10,
isRelevant: async () => true, isRelevant: async () => true,
}, },

View File

@@ -17,7 +17,6 @@ import {
notifySessionMetadataChanged, notifySessionMetadataChanged,
type SessionExternalMetadata, type SessionExternalMetadata,
} from '../utils/sessionState.js' } from '../utils/sessionState.js'
import { updateSettingsForSource } from '../utils/settings/settings.js'
import type { AppState } from './AppStateStore.js' import type { AppState } from './AppStateStore.js'
// Inverse of the push below — restore on worker restart. // Inverse of the push below — restore on worker restart.
@@ -91,23 +90,11 @@ export function onChangeAppState({
notifyPermissionModeChanged(newMode) notifyPermissionModeChanged(newMode)
} }
// mainLoopModel: remove it from settings? // mainLoopModel: session-scoped only (do NOT persist to userSettings).
if ( // Writing to settings.json would leak model changes into other running
newState.mainLoopModel !== oldState.mainLoopModel && // sessions (anthropics/claude-code#37596). Each process keeps its own
newState.mainLoopModel === null // model override in memory via setMainLoopModelOverride.
) { if (newState.mainLoopModel !== oldState.mainLoopModel) {
// Remove from settings
updateSettingsForSource('userSettings', { model: undefined })
setMainLoopModelOverride(null)
}
// mainLoopModel: add it to settings?
if (
newState.mainLoopModel !== oldState.mainLoopModel &&
newState.mainLoopModel !== null
) {
// Save to settings
updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
setMainLoopModelOverride(newState.mainLoopModel) setMainLoopModelOverride(newState.mainLoopModel)
} }

View File

@@ -2201,6 +2201,7 @@ async function getRelevantMemoryAttachments(
recentTools: readonly string[], recentTools: readonly string[],
signal: AbortSignal, signal: AbortSignal,
alreadySurfaced: ReadonlySet<string>, alreadySurfaced: ReadonlySet<string>,
parentSpan?: unknown,
): Promise<Attachment[]> { ): Promise<Attachment[]> {
// If an agent is @-mentioned, search only its memory dir (isolation). // If an agent is @-mentioned, search only its memory dir (isolation).
// Otherwise search the auto-memory dir. // Otherwise search the auto-memory dir.
@@ -2221,6 +2222,7 @@ async function getRelevantMemoryAttachments(
signal, signal,
recentTools, recentTools,
alreadySurfaced, alreadySurfaced,
parentSpan as Parameters<typeof findRelevantMemories>[5],
).catch(() => []), ).catch(() => []),
), ),
) )
@@ -2370,6 +2372,12 @@ export function startRelevantMemoryPrefetch(
return undefined return undefined
} }
// Poor mode: skip the side-query to save tokens
const { isPoorModeActive } = require('../commands/poor/poorMode.js') as typeof import('../commands/poor/poorMode.js')
if (isPoorModeActive()) {
return undefined
}
const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta) const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta)
if (!lastUserMessage) { if (!lastUserMessage) {
return undefined return undefined
@@ -2397,6 +2405,7 @@ export function startRelevantMemoryPrefetch(
collectRecentSuccessfulTools(messages, lastUserMessage), collectRecentSuccessfulTools(messages, lastUserMessage),
controller.signal, controller.signal,
surfaced.paths, surfaced.paths,
toolUseContext.langfuseTrace,
).catch(e => { ).catch(e => {
if (!isAbortError(e)) { if (!isAbortError(e)) {
logError(e) logError(e)

View File

@@ -133,6 +133,12 @@ export function calculateContextPercentages(
currentUsage.cache_creation_input_tokens + currentUsage.cache_creation_input_tokens +
currentUsage.cache_read_input_tokens currentUsage.cache_read_input_tokens
// Treat zero input tokens the same as no usage data — avoids flashing
// "ctx:0%" when a third-party API omits usage from message_start.
if (totalInputTokens === 0) {
return { used: null, remaining: null }
}
const usedPercentage = Math.round( const usedPercentage = Math.round(
(totalInputTokens / contextWindowSize) * 100, (totalInputTokens / contextWindowSize) * 100,
) )

View File

@@ -126,6 +126,12 @@ export function getDefaultOpusModel(): ModelName {
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) { if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
} }
// Fall back to user's configured model — custom providers may not
// recognize hardcoded Anthropic model IDs.
const userSpecifiedOpus = getUserSpecifiedModelSetting()
if (userSpecifiedOpus) {
return parseUserSpecifiedModel(userSpecifiedOpus)
}
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
// even when values match, since 3P availability lags firstParty and // even when values match, since 3P availability lags firstParty and
// these will diverge again at the next model launch. // these will diverge again at the next model launch.
@@ -153,6 +159,13 @@ export function getDefaultSonnetModel(): ModelName {
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) { if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
} }
// Fall back to user's configured model (ANTHROPIC_MODEL / settings) —
// custom providers (proxies, national clouds) may not recognize the
// hardcoded Anthropic model IDs.
const userSpecified = getUserSpecifiedModelSetting()
if (userSpecified) {
return parseUserSpecifiedModel(userSpecified)
}
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet // Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
if (provider !== 'firstParty') { if (provider !== 'firstParty') {
return getModelStrings().sonnet45 return getModelStrings().sonnet45
@@ -175,6 +188,12 @@ export function getDefaultHaikuModel(): ModelName {
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
} }
// Fall back to user's configured model — custom providers may not
// recognize hardcoded Anthropic model IDs.
const userSpecifiedHaiku = getUserSpecifiedModelSetting()
if (userSpecifiedHaiku) {
return parseUserSpecifiedModel(userSpecifiedHaiku)
}
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex) // Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
return getModelStrings().haiku45 return getModelStrings().haiku45

View File

@@ -0,0 +1,204 @@
/**
* Tests for src/utils/permissions/getNextPermissionMode.ts
*
* Covers the unified permission mode cycling logic:
* default → acceptEdits → plan → auto → bypassPermissions → default
*
* After the "open auto/bypass to all users" change, there is no USER_TYPE
* distinction — all users share the same cycle order.
*/
import { describe, expect, test } from 'bun:test'
import type { ToolPermissionContext } from '../../../Tool.js'
import type { PermissionMode } from '../PermissionMode.js'
// Inline getNextPermissionMode to avoid importing the heavy permissionSetup
// dependency chain (growthbook, settings, etc.).
// The function under test is small and pure enough to copy for testing.
import { getNextPermissionMode } from '../getNextPermissionMode.js'
// ─── helpers ──────────────────────────────────────────────────────────────────
function makeContext(
mode: PermissionMode,
overrides: Partial<ToolPermissionContext> = {},
): ToolPermissionContext {
return {
mode,
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: true,
...overrides,
}
}
// ─── tests ────────────────────────────────────────────────────────────────────
describe('getNextPermissionMode', () => {
// ── Full cycle ──────────────────────────────────────────────────────────
describe('unified cycle order', () => {
test('default → acceptEdits', () => {
expect(getNextPermissionMode(makeContext('default'))).toBe('acceptEdits')
})
test('acceptEdits → plan', () => {
expect(getNextPermissionMode(makeContext('acceptEdits'))).toBe('plan')
})
test('plan → auto', () => {
expect(getNextPermissionMode(makeContext('plan'))).toBe('auto')
})
test('auto → bypassPermissions (when bypass available)', () => {
expect(getNextPermissionMode(makeContext('auto'))).toBe('bypassPermissions')
})
test('bypassPermissions → default', () => {
expect(getNextPermissionMode(makeContext('bypassPermissions'))).toBe('default')
})
test('full cycle completes back to default', () => {
const cycle: PermissionMode[] = []
let ctx = makeContext('default')
for (let i = 0; i < 5; i++) {
const next = getNextPermissionMode(ctx)
cycle.push(next)
ctx = makeContext(next)
}
expect(cycle).toEqual([
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'default',
])
})
})
// ── auto → default when bypass unavailable ─────────────────────────────
describe('auto mode with bypass unavailable', () => {
test('auto → default when isBypassPermissionsModeAvailable is false', () => {
const ctx = makeContext('auto', {
isBypassPermissionsModeAvailable: false,
})
expect(getNextPermissionMode(ctx)).toBe('default')
})
})
// ── dontAsk mode ────────────────────────────────────────────────────────
describe('dontAsk mode', () => {
test('dontAsk → default', () => {
expect(getNextPermissionMode(makeContext('dontAsk'))).toBe('default')
})
})
// ── USER_TYPE independence ──────────────────────────────────────────────
describe('no USER_TYPE distinction', () => {
test('cycle order is the same regardless of USER_TYPE', () => {
// Save original
const originalUserType = process.env.USER_TYPE
// Test with no USER_TYPE
delete process.env.USER_TYPE
const cycleNoType: PermissionMode[] = []
let ctx = makeContext('default')
for (let i = 0; i < 5; i++) {
const next = getNextPermissionMode(ctx)
cycleNoType.push(next)
ctx = makeContext(next)
}
// Test with USER_TYPE=ant
process.env.USER_TYPE = 'ant'
const cycleAnt: PermissionMode[] = []
ctx = makeContext('default')
for (let i = 0; i < 5; i++) {
const next = getNextPermissionMode(ctx)
cycleAnt.push(next)
ctx = makeContext(next)
}
// Restore
if (originalUserType !== undefined) {
process.env.USER_TYPE = originalUserType
} else {
delete process.env.USER_TYPE
}
// Both should produce the same cycle
expect(cycleNoType).toEqual(cycleAnt)
expect(cycleNoType).toEqual([
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'default',
])
})
})
// ── teamContext parameter ───────────────────────────────────────────────
describe('teamContext parameter', () => {
test('does not affect cycle when provided', () => {
const ctx = makeContext('default')
const teamCtx = { leadAgentId: 'agent-123' }
expect(getNextPermissionMode(ctx, teamCtx)).toBe('acceptEdits')
})
test('does not affect cycle for plan mode', () => {
const ctx = makeContext('plan')
const teamCtx = { leadAgentId: 'agent-456' }
expect(getNextPermissionMode(ctx, teamCtx)).toBe('auto')
})
})
// ── cycle stability (no infinite loops) ─────────────────────────────────
describe('cycle stability', () => {
test('all modes return to default within 6 steps', () => {
const modes: PermissionMode[] = [
'default',
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'dontAsk',
]
for (const startMode of modes) {
let current = startMode
let returnedToDefault = false
for (let i = 0; i < 6; i++) {
current = getNextPermissionMode(makeContext(current))
if (current === 'default') {
returnedToDefault = true
break
}
}
expect(returnedToDefault).toBe(true)
}
})
test('cycling 100 times never produces an invalid mode', () => {
const validModes = new Set<string>([
'default',
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'dontAsk',
])
let ctx = makeContext('default')
for (let i = 0; i < 100; i++) {
const next = getNextPermissionMode(ctx)
expect(validModes.has(next)).toBe(true)
ctx = makeContext(next)
}
})
})
})

View File

@@ -0,0 +1,148 @@
/**
* Tests for the simplified permission gate functions.
*
* After the "open auto/bypass to all users" change, the key guarantees are:
* - shouldDisableBypassPermissions() always returns false
* - isBypassPermissionsModeDisabled() always returns false
* - hasAutoModeOptInAnySource() always returns true
* - isAutoModeGateEnabled() returns true unless fast-mode circuit breaker fires
* - getAutoModeUnavailableReason() returns null when no breaker fires
*
* These functions are tested through the getNextPermissionMode cycle
* and through direct unit tests of the gate functions.
*/
import { describe, expect, test } from 'bun:test'
import type { ToolPermissionContext } from '../../../Tool.js'
import type { PermissionMode } from '../PermissionMode.js'
import { getNextPermissionMode } from '../getNextPermissionMode.js'
// ─── helpers ──────────────────────────────────────────────────────────────────
function makeContext(
mode: PermissionMode,
overrides: Partial<ToolPermissionContext> = {},
): ToolPermissionContext {
return {
mode,
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: true,
...overrides,
}
}
// ─── tests ────────────────────────────────────────────────────────────────────
describe('permission gate invariants (after opening auto/bypass)', () => {
// ── Bypass permissions is always available ──────────────────────────────
describe('bypass mode always reachable in cycle', () => {
test('auto → bypassPermissions when isBypassPermissionsModeAvailable is true', () => {
const ctx = makeContext('auto', { isBypassPermissionsModeAvailable: true })
expect(getNextPermissionMode(ctx)).toBe('bypassPermissions')
})
test('isBypassPermissionsModeAvailable true is the default from getEmptyToolPermissionContext', () => {
// This test verifies the Tool.ts default is true
// (imported indirectly through the cycle behavior)
const ctx = makeContext('auto')
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
expect(getNextPermissionMode(ctx)).toBe('bypassPermissions')
})
})
// ── Auto mode is always available in cycle ──────────────────────────────
describe('auto mode always reachable in cycle', () => {
test('plan → auto (always, no gate check)', () => {
expect(getNextPermissionMode(makeContext('plan'))).toBe('auto')
})
test('plan → auto even when isBypassPermissionsModeAvailable is false', () => {
const ctx = makeContext('plan', { isBypassPermissionsModeAvailable: false })
expect(getNextPermissionMode(ctx)).toBe('auto')
})
test('bypassPermissions → default (then default → acceptEdits → plan → auto)', () => {
// Verify that after bypass, you can reach auto by cycling through
const fromBypass = getNextPermissionMode(makeContext('bypassPermissions'))
expect(fromBypass).toBe('default')
const fromDefault = getNextPermissionMode(makeContext('default'))
expect(fromDefault).toBe('acceptEdits')
const fromAcceptEdits = getNextPermissionMode(makeContext('acceptEdits'))
expect(fromAcceptEdits).toBe('plan')
const fromPlan = getNextPermissionMode(makeContext('plan'))
expect(fromPlan).toBe('auto')
})
})
// ── No opt-in gate between modes ────────────────────────────────────────
describe('no opt-in gate between modes', () => {
test('cycling from default to auto completes in 3 steps without any opt-in check', () => {
let mode: PermissionMode = 'default'
const steps: PermissionMode[] = []
// default → acceptEdits → plan → auto
for (let i = 0; i < 3; i++) {
mode = getNextPermissionMode(makeContext(mode))
steps.push(mode)
}
expect(steps).toEqual(['acceptEdits', 'plan', 'auto'])
})
test('cycling from default to bypassPermissions completes in 4 steps', () => {
let mode: PermissionMode = 'default'
const steps: PermissionMode[] = []
for (let i = 0; i < 4; i++) {
mode = getNextPermissionMode(makeContext(mode))
steps.push(mode)
}
expect(steps).toEqual(['acceptEdits', 'plan', 'auto', 'bypassPermissions'])
})
})
// ── Mode ordering safety (most dangerous modes last) ────────────────────
describe('safety ordering', () => {
test('auto comes before bypassPermissions in the cycle', () => {
// Starting from plan, user must press Shift+Tab twice to reach bypass
// (plan → auto → bypassPermissions)
const fromPlan = getNextPermissionMode(makeContext('plan'))
expect(fromPlan).toBe('auto')
const fromAuto = getNextPermissionMode(makeContext('auto'))
expect(fromAuto).toBe('bypassPermissions')
})
test('default comes before any dangerous mode', () => {
// default → acceptEdits (safe, just auto-accept edits)
const fromDefault = getNextPermissionMode(makeContext('default'))
expect(fromDefault).toBe('acceptEdits')
// acceptEdits is the least dangerous mode
})
})
})
describe('Tool.ts default context', () => {
test('getEmptyToolPermissionContext has isBypassPermissionsModeAvailable = true', async () => {
const { getEmptyToolPermissionContext } = await import('../../../Tool.js')
const ctx = getEmptyToolPermissionContext()
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
})
})
describe('settings hasAutoModeOptIn', () => {
test('always returns true after change', async () => {
const { hasAutoModeOptIn } = await import('../../settings/settings.js')
expect(hasAutoModeOptIn()).toBe(true)
})
})

View File

@@ -1,79 +1,44 @@
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import { useNotifications } from 'src/context/notifications.js'
type AppState, import { toError } from '../../utils/errors.js'
useAppState, import { logError } from '../../utils/log.js'
useAppStateStore,
useSetAppState,
} from 'src/state/AppState.js'
import type { ToolPermissionContext } from 'src/Tool.js'
import { getIsRemoteMode } from '../../bootstrap/state.js' import { getIsRemoteMode } from '../../bootstrap/state.js'
import { useAppState, useAppStateStore, useSetAppState } from '../../state/AppState.js'
import type { ToolPermissionContext } from '../../Tool.js'
import { import {
createDisabledBypassPermissionsContext,
shouldDisableBypassPermissions,
verifyAutoModeGateAccess, verifyAutoModeGateAccess,
} from './permissionSetup.js' } from './permissionSetup.js'
let bypassPermissionsCheckRan = false /**
* No-op — bypass permissions is always available.
*/
export async function checkAndDisableBypassPermissionsIfNeeded( export async function checkAndDisableBypassPermissionsIfNeeded(
toolPermissionContext: ToolPermissionContext, _toolPermissionContext: ToolPermissionContext,
setAppState: (f: (prev: AppState) => AppState) => void, _setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void,
): Promise<void> { ): Promise<void> {
// Check if bypassPermissions should be disabled based on Statsig gate // Bypass permissions is always available — no gate check needed
// Do this only once, before the first query, to ensure we have the latest gate value
if (bypassPermissionsCheckRan) {
return
}
bypassPermissionsCheckRan = true
if (!toolPermissionContext.isBypassPermissionsModeAvailable) {
return
}
const shouldDisable = await shouldDisableBypassPermissions()
if (!shouldDisable) {
return
}
setAppState(prev => {
return {
...prev,
toolPermissionContext: createDisabledBypassPermissionsContext(
prev.toolPermissionContext,
),
}
})
} }
/** /**
* Reset the run-once flag for checkAndDisableBypassPermissionsIfNeeded. * Reset stub — kept for interface compatibility.
* Call this after /login so the gate check re-runs with the new org.
*/ */
export function resetBypassPermissionsCheck(): void { export function resetBypassPermissionsCheck(): void {
bypassPermissionsCheckRan = false // No-op
} }
/**
* No-op hook — bypass permissions is always available.
*/
export function useKickOffCheckAndDisableBypassPermissionsIfNeeded(): void { export function useKickOffCheckAndDisableBypassPermissionsIfNeeded(): void {
const toolPermissionContext = useAppState(s => s.toolPermissionContext) // No-op
const setAppState = useSetAppState()
// Run once, when the component mounts
useEffect(() => {
if (getIsRemoteMode()) return
void checkAndDisableBypassPermissionsIfNeeded(
toolPermissionContext,
setAppState,
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
} }
let autoModeCheckRan = false let autoModeCheckRan = false
export async function checkAndDisableAutoModeIfNeeded( export async function checkAndDisableAutoModeIfNeeded(
toolPermissionContext: ToolPermissionContext, toolPermissionContext: ToolPermissionContext,
setAppState: (f: (prev: AppState) => AppState) => void, setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void,
fastMode?: boolean, fastMode?: boolean,
): Promise<void> { ): Promise<void> {
if (feature('TRANSCRIPT_CLASSIFIER')) { if (feature('TRANSCRIPT_CLASSIFIER')) {
@@ -87,10 +52,6 @@ export async function checkAndDisableAutoModeIfNeeded(
fastMode, fastMode,
) )
setAppState(prev => { setAppState(prev => {
// Apply the transform to CURRENT context, not the stale snapshot we
// passed to verifyAutoModeGateAccess. The async GrowthBook await inside
// can be outrun by a mid-turn shift-tab; spreading a stale context here
// would revert the user's mode change.
const nextCtx = updateContext(prev.toolPermissionContext) const nextCtx = updateContext(prev.toolPermissionContext)
const newState = const newState =
nextCtx === prev.toolPermissionContext nextCtx === prev.toolPermissionContext
@@ -133,11 +94,6 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void {
const isFirstRunRef = useRef(true) const isFirstRunRef = useRef(true)
// Runs on mount (startup check) AND whenever the model or fast mode changes // Runs on mount (startup check) AND whenever the model or fast mode changes
// (kick-out / carousel-restore). Watching both model fields covers /model,
// Cmd+P picker, /config, and bridge onSetModel paths; fastMode covers
// /fast on|off for the tengu_auto_mode_config.disableFastMode circuit
// breaker. The print.ts headless paths are covered by the sync
// isAutoModeGateEnabled() check.
useEffect(() => { useEffect(() => {
if (getIsRemoteMode()) return if (getIsRemoteMode()) return
if (isFirstRunRef.current) { if (isFirstRunRef.current) {
@@ -149,7 +105,9 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void {
store.getState().toolPermissionContext, store.getState().toolPermissionContext,
setAppState, setAppState,
fastMode, fastMode,
) ).catch(error => {
logError(new Error('Auto mode gate check failed', { cause: toError(error) }))
})
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mainLoopModel, mainLoopModelForSession, fastMode]) }, [mainLoopModel, mainLoopModelForSession, fastMode])
} }

View File

@@ -1,35 +1,13 @@
import { feature } from 'bun:bundle'
import type { ToolPermissionContext } from '../../Tool.js' import type { ToolPermissionContext } from '../../Tool.js'
import { logForDebugging } from '../debug.js' import { logForDebugging } from '../debug.js'
import type { PermissionMode } from './PermissionMode.js' import type { PermissionMode } from './PermissionMode.js'
import { import { transitionPermissionMode } from './permissionSetup.js'
getAutoModeUnavailableReason,
isAutoModeGateEnabled,
transitionPermissionMode,
} from './permissionSetup.js'
// Checks both the cached isAutoModeAvailable (set at startup by
// verifyAutoModeGateAccess) and the live isAutoModeGateEnabled() — these can
// diverge if the circuit breaker or settings change mid-session. The
// live check prevents transitionPermissionMode from throwing
// (permissionSetup.ts:~559), which would silently crash the shift+tab handler
// and leave the user stuck at the current mode.
function canCycleToAuto(ctx: ToolPermissionContext): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) {
const gateEnabled = isAutoModeGateEnabled()
const can = !!ctx.isAutoModeAvailable && gateEnabled
if (!can) {
logForDebugging(
`[auto-mode] canCycleToAuto=false: ctx.isAutoModeAvailable=${ctx.isAutoModeAvailable} isAutoModeGateEnabled=${gateEnabled} reason=${getAutoModeUnavailableReason()}`,
)
}
return can
}
return false
}
/** /**
* Determines the next permission mode when cycling through modes with Shift+Tab. * Determines the next permission mode when cycling through modes with Shift+Tab.
*
* Unified cycle for all users (no USER_TYPE distinction):
* default → acceptEdits → plan → auto → bypassPermissions → default
*/ */
export function getNextPermissionMode( export function getNextPermissionMode(
toolPermissionContext: ToolPermissionContext, toolPermissionContext: ToolPermissionContext,
@@ -37,43 +15,29 @@ export function getNextPermissionMode(
): PermissionMode { ): PermissionMode {
switch (toolPermissionContext.mode) { switch (toolPermissionContext.mode) {
case 'default': case 'default':
// Ants skip acceptEdits and plan — auto mode replaces them
if (process.env.USER_TYPE === 'ant') {
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
return 'bypassPermissions'
}
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
}
return 'acceptEdits' return 'acceptEdits'
case 'acceptEdits': case 'acceptEdits':
return 'plan' return 'plan'
case 'plan': case 'plan':
return 'auto'
case 'auto':
if (toolPermissionContext.isBypassPermissionsModeAvailable) { if (toolPermissionContext.isBypassPermissionsModeAvailable) {
return 'bypassPermissions' return 'bypassPermissions'
} }
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default' return 'default'
case 'bypassPermissions': case 'bypassPermissions':
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default' return 'default'
case 'dontAsk': case 'dontAsk':
// Not exposed in UI cycle yet, but return default if somehow reached // Not exposed in UI cycle yet, but return default if somehow reached
return 'default' return 'default'
default: default:
// Covers auto (when TRANSCRIPT_CLASSIFIER is enabled) and any future modes — always fall back to default // Covers any future modes — always fall back to default
return 'default' return 'default'
} }
} }

View File

@@ -799,10 +799,6 @@ export function initialPermissionModeFromCLI({
result = { mode: 'default', notification } result = { mode: 'default', notification }
} }
if (!result) {
result = { mode: 'default', notification }
}
if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') { if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') {
autoModeStateModule?.setAutoModeActive(true) autoModeStateModule?.setAutoModeActive(true)
} }
@@ -927,20 +923,9 @@ export async function initializeToolPermissionContext({
}) })
} }
// Check if bypassPermissions mode is available (not disabled by Statsig gate or settings) // Bypass permissions mode is available to all users
// Use cached values to avoid blocking on startup const isBypassPermissionsModeAvailable = true
const growthBookDisableBypassPermissionsMode =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
'tengu_disable_bypass_permissions_mode',
)
const settings = getSettings_DEPRECATED() || {} const settings = getSettings_DEPRECATED() || {}
const settingsDisableBypassPermissionsMode =
settings.permissions?.disableBypassPermissionsMode === 'disable'
const isBypassPermissionsModeAvailable =
(permissionMode === 'bypassPermissions' ||
allowDangerouslySkipPermissions) &&
!growthBookDisableBypassPermissionsMode &&
!settingsDisableBypassPermissionsMode
// Load all permission rules from disk // Load all permission rules from disk
const rulesFromDisk = loadAllPermissionRulesFromDisk() const rulesFromDisk = loadAllPermissionRulesFromDisk()
@@ -984,7 +969,7 @@ export async function initializeToolPermissionContext({
alwaysAskRules: {}, alwaysAskRules: {},
isBypassPermissionsModeAvailable, isBypassPermissionsModeAvailable,
...(feature('TRANSCRIPT_CLASSIFIER') ...(feature('TRANSCRIPT_CLASSIFIER')
? { isAutoModeAvailable: isAutoModeGateEnabled() } ? { isAutoModeAvailable: true }
: {}), : {}),
}, },
rulesFromDisk, rulesFromDisk,
@@ -1076,131 +1061,54 @@ export function getAutoModeUnavailableNotification(
* kicking the user out of a mode they've already left during the await. * kicking the user out of a mode they've already left during the await.
*/ */
export async function verifyAutoModeGateAccess( export async function verifyAutoModeGateAccess(
currentContext: ToolPermissionContext, _currentContext: ToolPermissionContext,
// Runtime AppState.fastMode — passed from callers with AppState access so // Runtime AppState.fastMode — passed from callers with AppState access so
// the disableFastMode circuit breaker reads current state, not stale // the disableFastMode circuit breaker reads current state, not stale
// settings.fastMode (which is intentionally sticky across /model auto- // settings.fastMode (which is intentionally sticky across /model auto-
// downgrades). Optional for callers without AppState (e.g. SDK init paths). // downgrades). Optional for callers without AppState (e.g. SDK init paths).
fastMode?: boolean, fastMode?: boolean,
): Promise<AutoModeGateCheckResult> { ): Promise<AutoModeGateCheckResult> {
// Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out) // Only fast-mode circuit breaker remains. All other gates (GrowthBook,
// Fresh read of tengu_auto_mode_config.enabled — this async check runs once // settings, model support, opt-in) have been removed.
// after GrowthBook initialization and is the authoritative source for
// isAutoModeAvailable. The sync startup path uses stale cache; this
// corrects it. Circuit breaker (enabled==='disabled') takes effect here.
const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
enabled?: AutoModeEnabledState enabled?: AutoModeEnabledState
disableFastMode?: boolean disableFastMode?: boolean
}>('tengu_auto_mode_config', {}) }>('tengu_auto_mode_config', {})
const enabledState = parseAutoModeEnabledState(autoModeConfig?.enabled)
const disabledBySettings = isAutoModeDisabledBySettings()
// Treat settings-disable the same as GrowthBook 'disabled' for circuit-breaker
// semantics — blocks SDK/explicit re-entry via isAutoModeGateEnabled().
autoModeStateModule?.setAutoModeCircuitBroken(
enabledState === 'disabled' || disabledBySettings,
)
// Carousel availability: not circuit-broken, not disabled-by-settings,
// model supports it, disableFastMode breaker not firing, and (enabled or opted-in)
const mainModel = getMainLoopModel() const mainModel = getMainLoopModel()
// Temp circuit breaker: tengu_auto_mode_config.disableFastMode blocks auto
// mode when fast mode is on. Checks runtime AppState.fastMode (if provided)
// and, for ants, model name '-fast' substring (ant-internal fast models
// like capybara-v2-fast[1m] encode speed in the model ID itself).
// Remove once auto+fast mode interaction is validated.
const disableFastModeBreakerFires = const disableFastModeBreakerFires =
!!autoModeConfig?.disableFastMode && !!autoModeConfig?.disableFastMode &&
(!!fastMode || (!!fastMode ||
(process.env.USER_TYPE === 'ant' && (process.env.USER_TYPE === 'ant' &&
mainModel.toLowerCase().includes('-fast'))) mainModel.toLowerCase().includes('-fast')))
const modelSupported =
modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires // If fast-mode breaker fires, circuit-break auto mode
let carouselAvailable = false autoModeStateModule?.setAutoModeCircuitBroken(disableFastModeBreakerFires)
if (enabledState !== 'disabled' && !disabledBySettings && modelSupported) {
carouselAvailable =
enabledState === 'enabled' || hasAutoModeOptInAnySource()
}
// canEnterAuto gates explicit entry (--permission-mode auto, defaultMode: auto)
// — explicit entry IS an opt-in, so we only block on circuit breaker + settings + model
const canEnterAuto =
enabledState !== 'disabled' && !disabledBySettings && modelSupported
logForDebugging( logForDebugging(
`[auto-mode] verifyAutoModeGateAccess: enabledState=${enabledState} disabledBySettings=${disabledBySettings} model=${mainModel} modelSupported=${modelSupported} disableFastModeBreakerFires=${disableFastModeBreakerFires} carouselAvailable=${carouselAvailable} canEnterAuto=${canEnterAuto}`, `[auto-mode] verifyAutoModeGateAccess: disableFastModeBreakerFires=${disableFastModeBreakerFires}`,
) )
// Capture CLI-flag intent now (doesn't depend on context). if (!disableFastModeBreakerFires) {
const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false // Auto mode available — no kick-out needed
return { updateContext: ctx => ctx }
// Return a transform function that re-evaluates context-dependent conditions
// against the CURRENT context at setAppState time. The async GrowthBook
// results above (canEnterAuto, carouselAvailable, enabledState, reason) are
// closure-captured — those don't depend on context. But mode, prePlanMode,
// and isAutoModeAvailable checks MUST use the fresh ctx or a mid-await
// shift-tab gets reverted (or worse, the user stays in auto despite the
// circuit breaker if they entered auto DURING the await — which is possible
// because setAutoModeCircuitBroken above runs AFTER the await).
const setAvailable = (
ctx: ToolPermissionContext,
available: boolean,
): ToolPermissionContext => {
if (ctx.isAutoModeAvailable !== available) {
logForDebugging(
`[auto-mode] verifyAutoModeGateAccess setAvailable: ${ctx.isAutoModeAvailable} -> ${available}`,
)
}
return ctx.isAutoModeAvailable === available
? ctx
: { ...ctx, isAutoModeAvailable: available }
} }
if (canEnterAuto) { // Fast-mode breaker fired — kick out of auto if currently in it
return { updateContext: ctx => setAvailable(ctx, carouselAvailable) } const notification = getAutoModeUnavailableNotification('circuit-breaker')
}
// Gate is off or circuit-broken — determine reason (context-independent).
let reason: AutoModeUnavailableReason
if (disabledBySettings) {
reason = 'settings'
logForDebugging('auto mode disabled: disableAutoMode in settings', {
level: 'warn',
})
} else if (enabledState === 'disabled') {
reason = 'circuit-breaker'
logForDebugging(
'auto mode disabled: tengu_auto_mode_config.enabled === "disabled" (circuit breaker)',
{ level: 'warn' },
)
} else {
reason = 'model'
logForDebugging(
`auto mode disabled: model ${getMainLoopModel()} does not support auto mode`,
{ level: 'warn' },
)
}
const notification = getAutoModeUnavailableNotification(reason)
// Unified kick-out transform. Re-checks the FRESH ctx and only fires
// side effects (setAutoModeActive(false), setNeedsAutoModeExitAttachment)
// when the kick-out actually applies. This keeps autoModeActive in sync
// with toolPermissionContext.mode even if the user changed modes during
// the await: if they already left auto on their own, handleCycleMode
// already deactivated the classifier and we don't fire again; if they
// ENTERED auto during the await (possible before setAutoModeCircuitBroken
// landed), we kick them out here.
const kickOutOfAutoIfNeeded = ( const kickOutOfAutoIfNeeded = (
ctx: ToolPermissionContext, ctx: ToolPermissionContext,
): ToolPermissionContext => { ): ToolPermissionContext => {
const inAuto = ctx.mode === 'auto' const inAuto = ctx.mode === 'auto'
logForDebugging( logForDebugging(
`[auto-mode] kickOutOfAutoIfNeeded applying: ctx.mode=${ctx.mode} ctx.prePlanMode=${ctx.prePlanMode} reason=${reason}`, `[auto-mode] kickOutOfAutoIfNeeded (fast-mode): ctx.mode=${ctx.mode}`,
) )
// Plan mode with auto active: either from prePlanMode='auto' (entered
// from auto) or from opt-in (strippedDangerousRules present).
const inPlanWithAutoActive = const inPlanWithAutoActive =
ctx.mode === 'plan' && ctx.mode === 'plan' &&
(ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules) (ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules)
if (!inAuto && !inPlanWithAutoActive) { if (!inAuto && !inPlanWithAutoActive) {
return setAvailable(ctx, false) return { ...ctx, isAutoModeAvailable: false }
} }
if (inAuto) { if (inAuto) {
autoModeStateModule?.setAutoModeActive(false) autoModeStateModule?.setAutoModeActive(false)
@@ -1214,8 +1122,6 @@ export async function verifyAutoModeGateAccess(
isAutoModeAvailable: false, isAutoModeAvailable: false,
} }
} }
// Plan with auto active: deactivate auto, restore permissions, defuse
// prePlanMode so ExitPlanMode goes to default.
autoModeStateModule?.setAutoModeActive(false) autoModeStateModule?.setAutoModeActive(false)
setNeedsAutoModeExitAttachment(true) setNeedsAutoModeExitAttachment(true)
return { return {
@@ -1225,65 +1131,23 @@ export async function verifyAutoModeGateAccess(
} }
} }
// Notification decisions use the stale context — that's OK: we're deciding return { updateContext: kickOutOfAutoIfNeeded, notification }
// WHETHER to notify based on what the user WAS doing when this check started.
// (Side effects and mode mutation are decided inside the transform above,
// against the fresh ctx.)
const wasInAuto = currentContext.mode === 'auto'
// Auto was used during plan: entered from auto or opt-in auto active
const autoActiveDuringPlan =
currentContext.mode === 'plan' &&
(currentContext.prePlanMode === 'auto' ||
!!currentContext.strippedDangerousRules)
const wantedAuto = wasInAuto || autoActiveDuringPlan || autoModeFlagCli
if (!wantedAuto) {
// User didn't want auto at call time — no notification. But still apply
// the full kick-out transform: if they shift-tabbed INTO auto during the
// await (before setAutoModeCircuitBroken landed), we need to evict them.
return { updateContext: kickOutOfAutoIfNeeded }
}
if (wasInAuto || autoActiveDuringPlan) {
// User was in auto or had auto active during plan — kick out + notify.
return { updateContext: kickOutOfAutoIfNeeded, notification }
}
// autoModeFlagCli only: defaultMode was auto but sync check rejected it.
// Suppress notification if isAutoModeAvailable is already false (already
// notified on a prior check; prevents repeat notifications on successive
// unsupported-model switches).
return {
updateContext: kickOutOfAutoIfNeeded,
notification: currentContext.isAutoModeAvailable ? notification : undefined,
}
} }
/** /**
* Core logic to check if bypassPermissions should be disabled based on Statsig gate * Bypass permissions is always available — no remote gate check needed.
*/ */
export function shouldDisableBypassPermissions(): Promise<boolean> { export function shouldDisableBypassPermissions(): Promise<boolean> {
return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode') return Promise.resolve(false)
}
function isAutoModeDisabledBySettings(): boolean {
const settings = getSettings_DEPRECATED() || {}
return (
(settings as { disableAutoMode?: 'disable' }).disableAutoMode ===
'disable' ||
(settings.permissions as { disableAutoMode?: 'disable' } | undefined)
?.disableAutoMode === 'disable'
)
} }
/** /**
* Checks if auto mode can be entered: circuit breaker is not active and settings * Checks if auto mode can be entered: only fast-mode circuit breaker remains.
* have not disabled it. Synchronous. * Synchronous.
*/ */
export function isAutoModeGateEnabled(): boolean { export function isAutoModeGateEnabled(): boolean {
// Auto mode is available to all users — only fast-mode circuit breaker remains
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false
if (isAutoModeDisabledBySettings()) return false
if (!modelSupportsAutoMode(getMainLoopModel())) return false
return true return true
} }
@@ -1292,11 +1156,9 @@ export function isAutoModeGateEnabled(): boolean {
* Synchronous — uses state populated by verifyAutoModeGateAccess. * Synchronous — uses state populated by verifyAutoModeGateAccess.
*/ */
export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null { export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null {
if (isAutoModeDisabledBySettings()) return 'settings'
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) { if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) {
return 'circuit-breaker' return 'circuit-breaker'
} }
if (!modelSupportsAutoMode(getMainLoopModel())) return 'model'
return null return null
} }
@@ -1310,8 +1172,7 @@ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null
*/ */
export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in' export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in'
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'enabled'
feature('TRANSCRIPT_CLASSIFIER') ? 'enabled' : 'disabled'
function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState { function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState {
if (value === 'enabled' || value === 'disabled' || value === 'opt-in') { if (value === 'enabled' || value === 'disabled' || value === 'opt-in') {
@@ -1361,27 +1222,15 @@ export function getAutoModeEnabledStateIfCached():
* dialog or by IDE/Desktop settings toggle) * dialog or by IDE/Desktop settings toggle)
*/ */
export function hasAutoModeOptInAnySource(): boolean { export function hasAutoModeOptInAnySource(): boolean {
if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true return true
return hasAutoModeOptIn()
} }
/** /**
* Checks if bypassPermissions mode is currently disabled by Statsig gate or settings. * Checks if bypassPermissions mode is currently disabled by Statsig gate or settings.
* This is a synchronous version that uses cached Statsig values. * Always returns false — bypass is available to all users.
*/ */
export function isBypassPermissionsModeDisabled(): boolean { export function isBypassPermissionsModeDisabled(): boolean {
const growthBookDisableBypassPermissionsMode = return false
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
'tengu_disable_bypass_permissions_mode',
)
const settings = getSettings_DEPRECATED() || {}
const settingsDisableBypassPermissionsMode =
settings.permissions?.disableBypassPermissionsMode === 'disable'
return (
growthBookDisableBypassPermissionsMode ||
settingsDisableBypassPermissionsMode
)
} }
/** /**
@@ -1406,29 +1255,12 @@ export function createDisabledBypassPermissionsContext(
} }
/** /**
* Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate * No-op — bypass permissions is always available, no remote gate check needed.
* and returns an updated toolPermissionContext if needed
*/ */
export async function checkAndDisableBypassPermissions( export async function checkAndDisableBypassPermissions(
currentContext: ToolPermissionContext, _currentContext: ToolPermissionContext,
): Promise<void> { ): Promise<void> {
// Only proceed if bypassPermissions mode is available // Bypass permissions is always available — no gate check needed
if (!currentContext.isBypassPermissionsModeAvailable) {
return
}
const shouldDisable = await shouldDisableBypassPermissions()
if (!shouldDisable) {
return
}
// Gate is enabled, need to disable bypassPermissions mode
logForDebugging(
'bypassPermissions mode is being disabled by Statsig gate (async check)',
{ level: 'warn' },
)
void gracefulShutdown(1, 'bypass_permissions_disabled')
} }
export function isDefaultPermissionModeAuto(): boolean { export function isDefaultPermissionModeAuto(): boolean {
@@ -1446,11 +1278,7 @@ export function isDefaultPermissionModeAuto(): boolean {
*/ */
export function shouldPlanUseAutoMode(): boolean { export function shouldPlanUseAutoMode(): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) { if (feature('TRANSCRIPT_CLASSIFIER')) {
return ( return isAutoModeGateEnabled() && getUseAutoModeDuringPlan()
hasAutoModeOptIn() &&
isAutoModeGateEnabled() &&
getUseAutoModeDuringPlan()
)
} }
return false return false
} }

View File

@@ -696,6 +696,7 @@ export const hasPermissionsToUseTool: CanUseToolFn = async (
context.options.tools, context.options.tools,
appState.toolPermissionContext, appState.toolPermissionContext,
context.abortController.signal, context.abortController.signal,
context.langfuseTrace,
) )
} finally { } finally {
clearClassifierChecking(toolUseID) clearClassifierChecking(toolUseID)

View File

@@ -31,6 +31,7 @@ import { resolveAntModel } from '../model/antModels.js'
import { getMainLoopModel } from '../model/model.js' import { getMainLoopModel } from '../model/model.js'
import { getAutoModeConfig } from '../settings/settings.js' import { getAutoModeConfig } from '../settings/settings.js'
import { sideQuery } from '../sideQuery.js' import { sideQuery } from '../sideQuery.js'
import type { LangfuseSpan } from '../../services/langfuse/index.js'
import { jsonStringify } from '../slowOperations.js' import { jsonStringify } from '../slowOperations.js'
import { tokenCountWithEstimation } from '../tokens.js' import { tokenCountWithEstimation } from '../tokens.js'
import { import {
@@ -731,6 +732,7 @@ async function classifyYoloActionXml(
action: string action: string
}, },
mode: TwoStageMode, mode: TwoStageMode,
parentSpan?: LangfuseSpan | null,
): Promise<YoloClassifierResult> { ): Promise<YoloClassifierResult> {
const classifierType = const classifierType =
mode === 'both' mode === 'both'
@@ -791,6 +793,7 @@ async function classifyYoloActionXml(
signal, signal,
...(mode !== 'fast' && { stop_sequences: ['</block>'] }), ...(mode !== 'fast' && { stop_sequences: ['</block>'] }),
querySource: 'auto_mode', querySource: 'auto_mode',
parentSpan,
} }
const stage1Raw = await sideQuery(stage1Opts) const stage1Raw = await sideQuery(stage1Opts)
stage1DurationMs = Date.now() - stage1Start stage1DurationMs = Date.now() - stage1Start
@@ -877,6 +880,7 @@ async function classifyYoloActionXml(
maxRetries: getDefaultMaxRetries(), maxRetries: getDefaultMaxRetries(),
signal, signal,
querySource: 'auto_mode' as const, querySource: 'auto_mode' as const,
parentSpan,
} }
const stage2Raw = await sideQuery(stage2Opts) const stage2Raw = await sideQuery(stage2Opts)
const stage2DurationMs = Date.now() - stage2Start const stage2DurationMs = Date.now() - stage2Start
@@ -1015,6 +1019,7 @@ export async function classifyYoloAction(
tools: Tools, tools: Tools,
context: ToolPermissionContext, context: ToolPermissionContext,
signal: AbortSignal, signal: AbortSignal,
parentSpan?: LangfuseSpan | null,
): Promise<YoloClassifierResult> { ): Promise<YoloClassifierResult> {
const lookup = buildToolLookup(tools) const lookup = buildToolLookup(tools)
const actionCompact = toCompact(action, lookup) const actionCompact = toCompact(action, lookup)
@@ -1126,6 +1131,7 @@ export async function classifyYoloAction(
action: actionCompact, action: actionCompact,
}, },
getTwoStageMode(), getTwoStageMode(),
parentSpan,
) )
} }
const [disableThinking, thinkingPadding] = getClassifierThinkingConfig(model) const [disableThinking, thinkingPadding] = getClassifierThinkingConfig(model)
@@ -1156,6 +1162,7 @@ export async function classifyYoloAction(
maxRetries: getDefaultMaxRetries(), maxRetries: getDefaultMaxRetries(),
signal, signal,
querySource: 'auto_mode' as const, querySource: 'auto_mode' as const,
parentSpan,
} }
const result = await sideQuery(sideQueryOpts) const result = await sideQuery(sideQueryOpts)
void maybeDumpAutoMode(sideQueryOpts, result, start) void maybeDumpAutoMode(sideQueryOpts, result, start)

View File

@@ -894,20 +894,8 @@ export function hasSkipDangerousModePermissionPrompt(): boolean {
* a malicious project could otherwise auto-bypass the dialog (RCE risk). * a malicious project could otherwise auto-bypass the dialog (RCE risk).
*/ */
export function hasAutoModeOptIn(): boolean { export function hasAutoModeOptIn(): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) { // Auto mode is available to all users — no opt-in needed
const user = getSettingsForSource('userSettings')?.skipAutoPermissionPrompt return true
const local =
getSettingsForSource('localSettings')?.skipAutoPermissionPrompt
const flag = getSettingsForSource('flagSettings')?.skipAutoPermissionPrompt
const policy =
getSettingsForSource('policySettings')?.skipAutoPermissionPrompt
const result = !!(user || local || flag || policy)
logForDebugging(
`[auto-mode] hasAutoModeOptIn=${result} skipAutoPermissionPrompt: user=${user} local=${local} flag=${flag} policy=${policy}`,
)
return result
}
return false
} }
/** /**

View File

@@ -2,6 +2,7 @@ import type Anthropic from '@anthropic-ai/sdk'
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js' import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js'
import { import {
getLastApiCompletionTimestamp, getLastApiCompletionTimestamp,
getSessionId,
setLastApiCompletionTimestamp, setLastApiCompletionTimestamp,
} from '../bootstrap/state.js' } from '../bootstrap/state.js'
import { STRUCTURED_OUTPUTS_BETA_HEADER } from '../constants/betas.js' import { STRUCTURED_OUTPUTS_BETA_HEADER } from '../constants/betas.js'
@@ -14,8 +15,13 @@ import { logEvent } from '../services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js'
import { getAPIMetadata } from '../services/api/claude.js' import { getAPIMetadata } from '../services/api/claude.js'
import { getAnthropicClient } from '../services/api/client.js' import { getAnthropicClient } from '../services/api/client.js'
import { createTrace, createChildSpan, endTrace, recordLLMObservation } from '../services/langfuse/index.js'
import type { LangfuseSpan } from '../services/langfuse/index.js'
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../services/langfuse/convert.js'
import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js' import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
import { errorMessage } from './errors.js'
import { computeFingerprint } from './fingerprint.js' import { computeFingerprint } from './fingerprint.js'
import { getAPIProvider } from './model/providers.js'
import { normalizeModelStringForAPI } from './model/model.js' import { normalizeModelStringForAPI } from './model/model.js'
type MessageParam = Anthropic.MessageParam type MessageParam = Anthropic.MessageParam
@@ -61,6 +67,11 @@ export type SideQueryOptions = {
stop_sequences?: string[] stop_sequences?: string[]
/** Attributes this call in tengu_api_success for COGS joining against reporting.sampling_calls. */ /** Attributes this call in tengu_api_success for COGS joining against reporting.sampling_calls. */
querySource: QuerySource querySource: QuerySource
/** Parent Langfuse span to nest this side query under the main agent trace. */
parentSpan?: LangfuseSpan | null
/** When true, API failures are recorded as WARNING instead of ERROR in Langfuse.
* Use for optional/best-effort queries where failure is expected and handled gracefully. */
optional?: boolean
} }
/** /**
@@ -177,25 +188,51 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
} }
const normalizedModel = normalizeModelStringForAPI(model) const normalizedModel = normalizeModelStringForAPI(model)
const provider = getAPIProvider()
const start = Date.now() const start = Date.now()
// biome-ignore lint/plugin: this IS the wrapper that handles OAuth attribution const traceName = `side-query:${opts.querySource}`
const response = await client.beta.messages.create(
{ // When parentSpan is provided, create a child span nested under the
model: normalizedModel, // main agent trace; otherwise create a standalone root trace.
max_tokens, const langfuseTrace = opts.parentSpan
system: systemBlocks, ? createChildSpan(opts.parentSpan, {
messages, name: traceName,
...(tools && { tools }), sessionId: getSessionId(),
...(tool_choice && { tool_choice }), model: normalizedModel,
...(output_format && { output_config: { format: output_format } }), provider,
...(temperature !== undefined && { temperature }), querySource: opts.querySource,
...(stop_sequences && { stop_sequences }), })
...(thinkingConfig && { thinking: thinkingConfig }), : createTrace({
...(betas.length > 0 && { betas }), sessionId: getSessionId(),
metadata: getAPIMetadata(), model: normalizedModel,
}, provider,
{ signal }, name: traceName,
) querySource: opts.querySource,
})
let response: BetaMessage
try {
response = await client.beta.messages.create(
{
model: normalizedModel,
max_tokens,
system: systemBlocks,
messages,
...(tools && { tools }),
...(tool_choice && { tool_choice }),
...(output_format && { output_config: { format: output_format } }),
...(temperature !== undefined && { temperature }),
...(stop_sequences && { stop_sequences }),
...(thinkingConfig && { thinking: thinkingConfig }),
...(betas.length > 0 && { betas }),
metadata: getAPIMetadata(),
},
{ signal },
)
} catch (error) {
endTrace(langfuseTrace, { error: errorMessage(error) }, opts.optional ? 'interrupted' : 'error')
throw error
}
const requestId = const requestId =
(response as { _request_id?: string | null })._request_id ?? undefined (response as { _request_id?: string | null })._request_id ?? undefined
@@ -218,5 +255,32 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
}) })
setLastApiCompletionTimestamp(now) setLastApiCompletionTimestamp(now)
// Record LLM observation in Langfuse (no-op if not configured).
// Wrap SDK types into the internal message format expected by converters.
const wrappedInput = messages.map(m => ({
type: m.role === 'assistant' ? 'assistant' as const : 'user' as const,
message: { role: m.role, content: m.content },
})) as unknown as Parameters<typeof convertMessagesToLangfuse>[0]
const wrappedOutput = [{
type: 'assistant' as const,
message: { role: 'assistant' as const, content: response.content },
}] as unknown as Parameters<typeof convertOutputToLangfuse>[0]
recordLLMObservation(langfuseTrace, {
model: normalizedModel,
provider,
input: convertMessagesToLangfuse(wrappedInput, systemBlocks.length > 0 ? systemBlocks.map(b => b.text) : undefined),
output: convertOutputToLangfuse(wrappedOutput),
usage: {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens ?? undefined,
cache_read_input_tokens: response.usage.cache_read_input_tokens ?? undefined,
},
startTime: new Date(start),
endTime: new Date(),
...(tools && { tools: convertToolsToLangfuse(tools as unknown[]) }),
})
endTrace(langfuseTrace)
return response return response
} }

View File

@@ -150,9 +150,17 @@ export function getCurrentUsage(messages: Message[]): {
const message = messages[i] const message = messages[i]
const usage = message ? getTokenUsage(message) : undefined const usage = message ? getTokenUsage(message) : undefined
if (usage) { if (usage) {
const inputTokens =
(usage.input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0)
// Skip placeholder usage (all zeros) — third-party APIs may emit
// message_start without real usage data, causing the context counter
// to flash to 0. Fall through to the previous message instead.
if (inputTokens === 0 && (usage.output_tokens ?? 0) === 0) continue
return { return {
input_tokens: usage.input_tokens, input_tokens: usage.input_tokens ?? 0,
output_tokens: usage.output_tokens, output_tokens: usage.output_tokens ?? 0,
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
} }