Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cli/src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,7 +152,12 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
}));

// Forward messages to the queue
let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default';
const claudeSettings = readClaudeSettings();
const rawDefaultMode = claudeSettings?.permissions?.defaultMode;
const claudeDefaultMode = PermissionModeSchema.safeParse(rawDefaultMode).success
? rawDefaultMode 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
Expand Down
5 changes: 4 additions & 1 deletion hub/src/web/routes/machines.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import { z } from 'zod'
import type { PermissionMode } from '@hapi/protocol'
import type { SyncEngine } from '../../sync/syncEngine'
import type { WebAppEnv } from '../middleware/auth'
import { requireMachine } from './guards'
Expand All @@ -11,6 +12,7 @@ const spawnBodySchema = z.object({
effort: z.string().optional(),
modelReasoningEffort: z.string().optional(),
yolo: z.boolean().optional(),
permissionMode: z.string().optional(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] permissionMode should use the shared schema instead of z.string() plus a cast. As written, invalid values and modes that are valid globally but invalid for the requested agent can pass the API boundary and fail later in the runner/CLI path.

Suggested fix:

import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'
import { PermissionModeSchema } from '@hapi/protocol/schemas'

const spawnBodySchema = z.object({
    directory: z.string().min(1),
    agent: z.enum(['claude', 'codex', 'cursor', 'gemini', 'opencode']).optional(),
    model: z.string().optional(),
    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()
})

const agent = parsed.data.agent ?? 'claude'
if (parsed.data.permissionMode && !isPermissionModeAllowedForFlavor(parsed.data.permissionMode, agent)) {
    return c.json({ error: 'Invalid permissionMode' }, 400)
}

sessionType: z.enum(['simple', 'worktree']).optional(),
worktreeName: z.string().optional()
})
Expand Down Expand Up @@ -61,7 +63,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
parsed.data.sessionType,
parsed.data.worktreeName,
undefined,
parsed.data.effort
parsed.data.effort,
parsed.data.permissionMode as PermissionMode | undefined
)
return c.json(result)
})
Expand Down
5 changes: 3 additions & 2 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,11 +414,12 @@ export class ApiClient {
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string,
effort?: string
effort?: string,
permissionMode?: string
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/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 })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] permissionMode is added to the web request body here, but the hub spawn route still does not accept or forward it: hub/src/web/routes/machines.ts omits it from spawnBodySchema and from engine.spawnSession(...). Because Zod strips unknown keys, acceptEdits and plan never reach the runner, so the selector does not work for the new modes.

Suggested fix:

import { PermissionModeSchema } from '@hapi/protocol/schemas'

const spawnBodySchema = z.object({
    directory: z.string().min(1),
    agent: z.enum(['claude', 'codex', 'cursor', 'gemini', 'opencode']).optional(),
    model: z.string().optional(),
    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()
})

const result = await engine.spawnSession(
    machineId,
    parsed.data.directory,
    parsed.data.agent,
    parsed.data.model,
    parsed.data.modelReasoningEffort,
    parsed.data.yolo,
    parsed.data.sessionType,
    parsed.data.worktreeName,
    undefined,
    parsed.data.effort,
    parsed.data.permissionMode
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] permissionMode is added to the web request body here, but the hub spawn route still does not accept or forward it: hub/src/web/routes/machines.ts omits it from spawnBodySchema and from engine.spawnSession(...). Because Zod strips unknown keys, acceptEdits and plan never reach the runner.

Suggested fix:

import { PermissionModeSchema } from '@hapi/protocol/schemas'

const spawnBodySchema = z.object({
    directory: z.string().min(1),
    agent: z.enum(['claude', 'codex', 'cursor', 'gemini', 'opencode']).optional(),
    model: z.string().optional(),
    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()
})

const result = await engine.spawnSession(
    machineId,
    parsed.data.directory,
    parsed.data.agent,
    parsed.data.model,
    parsed.data.modelReasoningEffort,
    parsed.data.yolo,
    parsed.data.sessionType,
    parsed.data.worktreeName,
    undefined,
    parsed.data.effort,
    parsed.data.permissionMode
)

})
Comment on lines 420 to 423
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client now includes permissionMode in the spawn request body, but the server-side /api/machines/:id/spawn route currently does not read/forward permissionMode (it only passes yolo, effort, etc.). As a result, modes other than the legacy YOLO mapping will be ignored end-to-end. Either update the server route/schema to accept and pass permissionMode, or remove this field until the backend is wired up.

Copilot uses AI. Check for mistakes.
}

Expand Down
36 changes: 36 additions & 0 deletions web/src/components/NewSession/PermissionModeSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.permissionMode')}
</label>
<select
value={props.permissionMode}
onChange={(e) => props.onPermissionModeChange(e.target.value as ClaudePermissionMode)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{CLAUDE_PERMISSION_MODE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
38 changes: 0 additions & 38 deletions web/src/components/NewSession/YoloToggle.tsx

This file was deleted.

24 changes: 13 additions & 11 deletions web/src/components/NewSession/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,22 @@ 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'
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: {
Expand Down Expand Up @@ -53,7 +53,7 @@ export function NewSession(props: {
const [model, setModel] = useState('auto')
const [effort, setEffort] = useState<ClaudeEffort>('auto')
const [modelReasoningEffort, setModelReasoningEffort] = useState<CodexReasoningEffort>('default')
const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode)
const [permissionMode, setPermissionMode] = useState<ClaudePermissionMode>(loadPreferredPermissionMode)
const [sessionType, setSessionType] = useState<SessionType>('simple')
const [worktreeName, setWorktreeName] = useState('')
const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false)
Expand All @@ -76,8 +76,8 @@ export function NewSession(props: {
}, [agent])

useEffect(() => {
savePreferredYoloMode(yoloMode)
}, [yoloMode])
savePreferredPermissionMode(permissionMode)
}, [permissionMode])

useEffect(() => {
if (props.machines.length === 0) return
Expand Down Expand Up @@ -289,7 +289,8 @@ export function NewSession(props: {
model: resolvedModel,
effort: resolvedEffort,
modelReasoningEffort: resolvedModelReasoningEffort,
yolo: yoloMode,
yolo: permissionMode === 'bypassPermissions',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This still derives yolo from the saved Claude permissionMode even when the current agent is not Claude. Since PermissionModeSelector is hidden for non-Claude agents, a saved bypassPermissions value can silently spawn Codex/Gemini/Cursor/OpenCode with --yolo and no visible control.

Suggested fix:

const claudePermissionMode = agent === 'claude' ? permissionMode : undefined

const result = await spawnSession({
    machineId,
    directory: trimmedDirectory,
    agent,
    model: resolvedModel,
    effort: resolvedEffort,
    modelReasoningEffort: resolvedModelReasoningEffort,
    yolo: claudePermissionMode === 'bypassPermissions',
    permissionMode: claudePermissionMode,
    sessionType,
    worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})

Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yolo is derived solely from permissionMode and is sent even when agent !== 'claude'. If a user previously selected bypassPermissions for a Claude session and then switches to another agent, this will still send yolo: true (with the selector hidden), which can lead to unexpected/dangerous spawns or even CLI arg incompatibilities for non-Claude agents. Gate yolo behind the Claude agent (e.g., only set it when agent === 'claude' and mode is bypassPermissions).

Suggested change
yolo: permissionMode === 'bypassPermissions',
yolo: agent === 'claude' && permissionMode === 'bypassPermissions' ? true : undefined,

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This still derives yolo from the saved Claude permissionMode even when the current agent is not Claude. Since PermissionModeSelector is hidden for non-Claude agents, a saved bypassPermissions value can silently spawn Codex/Gemini/Cursor/OpenCode with unsafe mode and no visible control.

Suggested fix:

const claudePermissionMode = agent === 'claude' ? permissionMode : undefined

const result = await spawnSession({
    machineId,
    directory: trimmedDirectory,
    agent,
    model: resolvedModel,
    effort: resolvedEffort,
    modelReasoningEffort: resolvedModelReasoningEffort,
    yolo: claudePermissionMode === 'bypassPermissions',
    permissionMode: claudePermissionMode,
    sessionType,
    worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] Hidden Claude bypass state still sets YOLO for non-Claude spawns. PermissionModeSelector returns null for non-Claude agents, but this line still sends yolo: true whenever the persisted Claude mode is bypassPermissions. Gate both yolo and permissionMode on the selected agent.

Suggested fix:

const claudePermissionMode = agent === 'claude' ? permissionMode : undefined

const result = await spawnSession({
    machineId,
    directory: trimmedDirectory,
    agent,
    model: resolvedModel,
    effort: resolvedEffort,
    modelReasoningEffort: resolvedModelReasoningEffort,
    yolo: claudePermissionMode === 'bypassPermissions',
    permissionMode: claudePermissionMode,
    sessionType,
    worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})

permissionMode: agent === 'claude' ? permissionMode : undefined,
sessionType,
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})
Expand Down Expand Up @@ -378,10 +379,11 @@ export function NewSession(props: {
isDisabled={isFormDisabled}
onChange={setModelReasoningEffort}
/>
<YoloToggle
yoloMode={yoloMode}
<PermissionModeSelector
agent={agent}
permissionMode={permissionMode}
isDisabled={isFormDisabled}
onToggle={setYoloMode}
onPermissionModeChange={setPermissionMode}
/>

{(error ?? spawnError) ? (
Expand Down
29 changes: 28 additions & 1 deletion web/src/components/NewSession/preferences.ts
Original file line number Diff line number Diff line change
@@ -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']

Expand Down Expand Up @@ -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'
}
Comment on lines +46 to +61
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New loadPreferredPermissionMode/savePreferredPermissionMode logic (including the legacy yolo=true migration path) isn’t covered by the existing preferences.test.ts. Adding/adjusting tests would help prevent regressions, especially around validating stored values and the yolo→bypassPermissions migration behavior.

Copilot uses AI. Check for mistakes.

export function savePreferredPermissionMode(mode: ClaudePermissionMode): void {
try {
localStorage.setItem(PERMISSION_MODE_STORAGE_KEY, mode)
} catch {
// Ignore storage errors
}
}
8 changes: 7 additions & 1 deletion web/src/components/NewSession/types.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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] }))
4 changes: 3 additions & 1 deletion web/src/hooks/mutations/useSpawnSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type SpawnInput = {
effort?: string
modelReasoningEffort?: string
yolo?: boolean
permissionMode?: string
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

permissionMode is typed as string here, which loses the compile-time guarantees you already have elsewhere (e.g., PermissionMode / ClaudePermissionMode). Consider typing this as PermissionMode (or ClaudePermissionMode if it’s intentionally Claude-only) so invalid values can’t be threaded into the API call.

Suggested change
permissionMode?: string
permissionMode?: Parameters<ApiClient['spawnSession']>[10]

Copilot uses AI. Check for mistakes.
sessionType?: 'simple' | 'worktree'
worktreeName?: string
}
Expand All @@ -36,7 +37,8 @@ export function useSpawnSession(api: ApiClient | null): {
input.yolo,
input.sessionType,
input.worktreeName,
input.effort
input.effort,
input.permissionMode
)
},
onSuccess: () => {
Expand Down
4 changes: 1 addition & 3 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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…',

Expand Down
4 changes: 1 addition & 3 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '创建中…',

Expand Down
Loading