diff --git a/bun.lock b/bun.lock index b00aeeb883..cfa5096d1f 100644 --- a/bun.lock +++ b/bun.lock @@ -1062,6 +1062,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.17.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-W4Wzm5KPMvXhUkx8q5Yw4cjx3g4Xhg2I7nbn8CYaMXyhyZhsetN1zIY6P5ciQMcwuL3AXbJvvOgVOodxudw0CA=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.17.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-FhSkaVbnYOSiqs/4SVbL5RD9RWdV0KES014ovA8jf0qDSpcjMMqO3SygSnn6Rj05u84Na61tzULeCnYqcwkc/A=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index c7aae047bc..a194158d8f 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -20,6 +20,7 @@ import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { normalizeClaudeSessionModel } from './model'; import { normalizeClaudeSessionEffort } from './effort'; import { getInvokedCwd } from '@/utils/invokedCwd'; +import { readClaudeSettings } from '@/claude/utils/claudeSettings'; export interface StartOptions { model?: string @@ -151,7 +152,12 @@ export async function runClaude(options: StartOptions = {}): Promise { })); // Forward messages to the queue - let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default'; + const claudeSettings = readClaudeSettings(); + const parsedDefaultMode = PermissionModeSchema.safeParse(claudeSettings?.permissions?.defaultMode); + const claudeDefaultMode: PermissionMode | undefined = parsedDefaultMode.success && isPermissionModeAllowedForFlavor(parsedDefaultMode.data, 'claude') + ? parsedDefaultMode.data as PermissionMode + : undefined; + let currentPermissionMode: PermissionMode = options.permissionMode ?? claudeDefaultMode ?? 'default'; let currentModel: SessionModel = initialModel; let currentEffort: SessionEffort = initialEffort; let currentFallbackModel: string | undefined = undefined; // Track current fallback model diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index cf4a460553..ea84cddd40 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -1,5 +1,7 @@ import { Hono } from 'hono' import { z } from 'zod' +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' +import { PermissionModeSchema } from '@hapi/protocol/schemas' import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireMachine } from './guards' @@ -11,6 +13,7 @@ const spawnBodySchema = z.object({ effort: z.string().optional(), modelReasoningEffort: z.string().optional(), yolo: z.boolean().optional(), + permissionMode: PermissionModeSchema.optional(), sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional() }) @@ -51,17 +54,23 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Invalid body' }, 400) } + const agent = parsed.data.agent ?? 'claude' + if (parsed.data.permissionMode && !isPermissionModeAllowedForFlavor(parsed.data.permissionMode, agent)) { + return c.json({ error: 'Invalid permissionMode for agent' }, 400) + } + const result = await engine.spawnSession( machineId, parsed.data.directory, - parsed.data.agent, + agent, parsed.data.model, parsed.data.modelReasoningEffort, parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, undefined, - parsed.data.effort + parsed.data.effort, + parsed.data.permissionMode ) return c.json(result) }) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index d59320a9d0..b12f20ea90 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -5,7 +5,7 @@ */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan'] as const +export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'auto', 'dontAsk'] as const export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number] export const CODEX_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const @@ -28,6 +28,8 @@ export const PERMISSION_MODES = [ 'acceptEdits', 'bypassPermissions', 'plan', + 'auto', + 'dontAsk', 'ask', 'read-only', 'safe-yolo', @@ -41,8 +43,10 @@ export const PERMISSION_MODE_LABELS: Record = { default: 'Default', acceptEdits: 'Accept Edits', plan: 'Plan Mode', + auto: 'Auto', + dontAsk: "Don't Ask", ask: 'Ask Mode', - bypassPermissions: 'Yolo', + bypassPermissions: 'Bypass Permissions', 'read-only': 'Read Only', 'safe-yolo': 'Safe Yolo', yolo: 'Yolo' @@ -54,6 +58,8 @@ export const PERMISSION_MODE_TONES: Record = default: 'neutral', acceptEdits: 'warning', plan: 'info', + auto: 'neutral', + dontAsk: 'danger', ask: 'info', bypassPermissions: 'danger', 'read-only': 'warning', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 19c577b54e..379909cd88 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -414,11 +414,12 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: string, + permissionMode?: string ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, permissionMode }) }) } diff --git a/web/src/components/NewSession/PermissionModeSelector.tsx b/web/src/components/NewSession/PermissionModeSelector.tsx new file mode 100644 index 0000000000..331c2772b3 --- /dev/null +++ b/web/src/components/NewSession/PermissionModeSelector.tsx @@ -0,0 +1,36 @@ +import type { AgentType, ClaudePermissionMode } from './types' +import { CLAUDE_PERMISSION_MODE_OPTIONS } from './types' +import { useTranslation } from '@/lib/use-translation' + +export function PermissionModeSelector(props: { + agent: AgentType + permissionMode: ClaudePermissionMode + isDisabled: boolean + onPermissionModeChange: (value: ClaudePermissionMode) => void +}) { + const { t } = useTranslation() + + if (props.agent !== 'claude') { + return null + } + + return ( +
+ + +
+ ) +} diff --git a/web/src/components/NewSession/YoloToggle.tsx b/web/src/components/NewSession/YoloToggle.tsx deleted file mode 100644 index f0d0d25f54..0000000000 --- a/web/src/components/NewSession/YoloToggle.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useTranslation } from '@/lib/use-translation' - -export function YoloToggle(props: { - yoloMode: boolean - isDisabled: boolean - onToggle: (value: boolean) => void -}) { - const { t } = useTranslation() - - return ( -
- -
-
- - {t('newSession.yolo.title')} - - - {t('newSession.yolo.desc')} - -
- -
-
- ) -} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 721596b8d5..268b82af1b 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -10,7 +10,7 @@ import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggesti import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' import { useRecentPaths } from '@/hooks/useRecentPaths' import { useTranslation } from '@/lib/use-translation' -import type { AgentType, ClaudeEffort, CodexReasoningEffort, SessionType } from './types' +import type { AgentType, ClaudeEffort, ClaudePermissionMode, CodexReasoningEffort, SessionType } from './types' import { ActionButtons } from './ActionButtons' import { AgentSelector } from './AgentSelector' import { DirectorySection } from './DirectorySection' @@ -18,14 +18,14 @@ import { MachineSelector } from './MachineSelector' import { ModelSelector } from './ModelSelector' import { ClaudeEffortSelector } from './ClaudeEffortSelector' import { ReasoningEffortSelector } from './ReasoningEffortSelector' +import { PermissionModeSelector } from './PermissionModeSelector' import { loadPreferredAgent, - loadPreferredYoloMode, + loadPreferredPermissionMode, savePreferredAgent, - savePreferredYoloMode, + savePreferredPermissionMode, } from './preferences' import { SessionTypeSelector } from './SessionTypeSelector' -import { YoloToggle } from './YoloToggle' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' export function NewSession(props: { @@ -53,7 +53,7 @@ export function NewSession(props: { const [model, setModel] = useState('auto') const [effort, setEffort] = useState('auto') const [modelReasoningEffort, setModelReasoningEffort] = useState('default') - const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode) + const [permissionMode, setPermissionMode] = useState(loadPreferredPermissionMode) const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false) @@ -76,8 +76,8 @@ export function NewSession(props: { }, [agent]) useEffect(() => { - savePreferredYoloMode(yoloMode) - }, [yoloMode]) + savePreferredPermissionMode(permissionMode) + }, [permissionMode]) useEffect(() => { if (props.machines.length === 0) return @@ -282,6 +282,7 @@ export function NewSession(props: { const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default' ? modelReasoningEffort : undefined + const claudePermissionMode = agent === 'claude' ? permissionMode : undefined const result = await spawnSession({ machineId, directory: trimmedDirectory, @@ -289,7 +290,8 @@ export function NewSession(props: { model: resolvedModel, effort: resolvedEffort, modelReasoningEffort: resolvedModelReasoningEffort, - yolo: yoloMode, + yolo: claudePermissionMode === 'bypassPermissions', + permissionMode: claudePermissionMode, sessionType, worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined }) @@ -378,10 +380,11 @@ export function NewSession(props: { isDisabled={isFormDisabled} onChange={setModelReasoningEffort} /> - {(error ?? spawnError) ? ( diff --git a/web/src/components/NewSession/preferences.ts b/web/src/components/NewSession/preferences.ts index 84ac458f6b..e00fc6b14e 100644 --- a/web/src/components/NewSession/preferences.ts +++ b/web/src/components/NewSession/preferences.ts @@ -1,7 +1,9 @@ -import type { AgentType } from './types' +import type { AgentType, ClaudePermissionMode } from './types' +import { CLAUDE_PERMISSION_MODES } from '@hapi/protocol' const AGENT_STORAGE_KEY = 'hapi:newSession:agent' const YOLO_STORAGE_KEY = 'hapi:newSession:yolo' +const PERMISSION_MODE_STORAGE_KEY = 'hapi:newSession:permissionMode' const VALID_AGENTS: AgentType[] = ['claude', 'codex', 'cursor', 'gemini', 'opencode'] @@ -40,3 +42,28 @@ export function savePreferredYoloMode(enabled: boolean): void { // Ignore storage errors } } + +export function loadPreferredPermissionMode(): ClaudePermissionMode { + try { + const stored = localStorage.getItem(PERMISSION_MODE_STORAGE_KEY) + if (stored && (CLAUDE_PERMISSION_MODES as readonly string[]).includes(stored)) { + return stored as ClaudePermissionMode + } + // Migrate from legacy yolo toggle + if (localStorage.getItem(YOLO_STORAGE_KEY) === 'true') { + savePreferredPermissionMode('bypassPermissions') + return 'bypassPermissions' + } + } catch { + // Ignore storage errors + } + return 'default' +} + +export function savePreferredPermissionMode(mode: ClaudePermissionMode): void { + try { + localStorage.setItem(PERMISSION_MODE_STORAGE_KEY, mode) + } catch { + // Ignore storage errors + } +} diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index a950a6ff0c..464c561009 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -1,4 +1,7 @@ -import { GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS } from '@hapi/protocol' +import { GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS, CLAUDE_PERMISSION_MODES, PERMISSION_MODE_LABELS } from '@hapi/protocol' +import type { ClaudePermissionMode } from '@hapi/protocol' + +export type { ClaudePermissionMode } export type AgentType = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' export type SessionType = 'simple' | 'worktree' @@ -38,3 +41,6 @@ export const CLAUDE_EFFORT_OPTIONS: { value: ClaudeEffort; label: string }[] = [ { value: 'high', label: 'High' }, { value: 'max', label: 'Max' }, ] + +export const CLAUDE_PERMISSION_MODE_OPTIONS: { value: ClaudePermissionMode; label: string }[] = + CLAUDE_PERMISSION_MODES.map((mode) => ({ value: mode, label: PERMISSION_MODE_LABELS[mode] })) diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index 37f69f61a1..d2f1329f92 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -11,6 +11,7 @@ type SpawnInput = { effort?: string modelReasoningEffort?: string yolo?: boolean + permissionMode?: string sessionType?: 'simple' | 'worktree' worktreeName?: string } @@ -36,7 +37,8 @@ export function useSpawnSession(api: ApiClient | null): { input.yolo, input.sessionType, input.worktreeName, - input.effort + input.effort, + input.permissionMode ) }, onSuccess: () => { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 1596adbd3f..d4eaec8819 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -112,9 +112,7 @@ export default { 'newSession.model.optional': 'optional', 'newSession.model.loadFailed': 'Failed to load Codex models', 'newSession.reasoningEffort': 'Reasoning effort', - 'newSession.yolo': 'YOLO mode', - 'newSession.yolo.title': 'Bypass approvals and sandbox', - 'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.', + 'newSession.permissionMode': 'Permission Mode', 'newSession.create': 'Create', 'newSession.creating': 'Creating…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 8539a00d29..429c7b96b6 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -114,9 +114,7 @@ export default { 'newSession.model.optional': '可选', 'newSession.model.loadFailed': '加载 Codex 模型失败', 'newSession.reasoningEffort': '推理强度', - 'newSession.yolo': 'YOLO 模式', - 'newSession.yolo.title': '跳过审批和沙箱', - 'newSession.yolo.desc': '启动时使用危险的代理标志。', + 'newSession.permissionMode': '权限模式', 'newSession.create': '创建', 'newSession.creating': '创建中…',