mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ce08fe39 | ||
|
|
92f8a92fbb | ||
|
|
a67e2d0e97 | ||
|
|
8c629858ab |
@@ -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
|
||||||
|
|||||||
@@ -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 # 我们有自部署的远程控制
|
||||||
|
|||||||
43
build.ts
43
build.ts
@@ -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_'))
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const getEmptyToolPermissionContext: () => ToolPermissionContext =
|
|||||||
alwaysAllowRules: {},
|
alwaysAllowRules: {},
|
||||||
alwaysDenyRules: {},
|
alwaysDenyRules: {},
|
||||||
alwaysAskRules: {},
|
alwaysAskRules: {},
|
||||||
isBypassPermissionsModeAvailable: false,
|
isBypassPermissionsModeAvailable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type CompactProgressEvent =
|
export type CompactProgressEvent =
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
src/main.tsx
13
src/main.tsx
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
204
src/utils/permissions/__tests__/getNextPermissionMode.test.ts
Normal file
204
src/utils/permissions/__tests__/getNextPermissionMode.test.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
148
src/utils/permissions/__tests__/permissionSetup.test.ts
Normal file
148
src/utils/permissions/__tests__/permissionSetup.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user