Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 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
Expand Down
13 changes: 11 additions & 2 deletions hub/src/web/routes/machines.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
})
Expand Down Expand Up @@ -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
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] This forwards any globally valid PermissionModeSchema value without checking whether it is valid for the selected/default agent. buildCliArgs() then emits --permission-mode for any global mode, while the individual CLI commands reject modes outside their own flavor set, so agent: "codex", permissionMode: "bypassPermissions" fails later in the runner instead of returning 400 here.

Suggested fix:

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

)
return c.json(result)
})
Expand Down
10 changes: 8 additions & 2 deletions shared/src/modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +28,8 @@ export const PERMISSION_MODES = [
'acceptEdits',
'bypassPermissions',
'plan',
'auto',
'dontAsk',
'ask',
'read-only',
'safe-yolo',
Expand All @@ -41,8 +43,10 @@ export const PERMISSION_MODE_LABELS: Record<PermissionMode, string> = {
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'
Expand All @@ -54,6 +58,8 @@ export const PERMISSION_MODE_TONES: Record<PermissionMode, PermissionModeTone> =
default: 'neutral',
acceptEdits: 'warning',
plan: 'info',
auto: 'neutral',
dontAsk: 'danger',
ask: 'info',
bypassPermissions: 'danger',
'read-only': 'warning',
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.

25 changes: 14 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 @@ -282,14 +282,16 @@ export function NewSession(props: {
const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default'
? modelReasoningEffort
: undefined
const claudePermissionMode = agent === 'claude' ? permissionMode : 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] This now drops both yolo and permissionMode for every non-Claude agent, whereas the removed YoloToggle applied to all agents and let Codex/Gemini/Cursor/OpenCode start with their supported --yolo mode. That makes YOLO unavailable from the web new-session flow for those agents.

Suggested fix:

const nonClaudeYolo = agent !== 'claude' && yoloMode
const claudePermissionMode = agent === 'claude' ? permissionMode : undefined

await spawnSession({
    ...,
    yolo: claudePermissionMode === 'bypassPermissions' || nonClaudeYolo,
    permissionMode: claudePermissionMode,
})

const result = await spawnSession({
machineId,
directory: trimmedDirectory,
agent,
model: resolvedModel,
effort: resolvedEffort,
modelReasoningEffort: resolvedModelReasoningEffort,
yolo: yoloMode,
yolo: claudePermissionMode === '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 now derives yolo only from the Claude permission selector. Since PermissionModeSelector is hidden for non-Claude agents, Codex/Cursor/Gemini/OpenCode users no longer have a web UI path to spawn YOLO sessions, even though the old YoloToggle applied to all agents and those commands still support YOLO.

Suggested fix:

const [nonClaudeYoloMode, setNonClaudeYoloMode] = useState(loadPreferredYoloMode)
const claudePermissionMode = agent === 'claude' ? permissionMode : undefined
const nonClaudeYolo = agent !== 'claude' && nonClaudeYoloMode

await spawnSession({
    ...,
    yolo: claudePermissionMode === 'bypassPermissions' || nonClaudeYolo,
    permissionMode: claudePermissionMode,
})

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 derives yolo only from the Claude permission selector. Since PermissionModeSelector returns null for non-Claude agents, Codex/Cursor/Gemini/OpenCode users no longer have a web UI path to spawn YOLO sessions even though the old YoloToggle applied to all agents and those commands still support YOLO.

Suggested fix:

const [nonClaudeYoloMode, setNonClaudeYoloMode] = useState(loadPreferredYoloMode)
const claudePermissionMode = agent === 'claude' ? permissionMode : undefined
const nonClaudeYolo = agent !== 'claude' && nonClaudeYoloMode

await spawnSession({
    ...,
    yolo: claudePermissionMode === 'bypassPermissions' || nonClaudeYolo,
    permissionMode: claudePermissionMode,
})

permissionMode: claudePermissionMode,
sessionType,
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})
Expand Down Expand Up @@ -378,10 +380,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