Skip to content

feat: replace YoloToggle with PermissionModeSelector for Claude sessions#529

Open
ye4241 wants to merge 10 commits intotiann:mainfrom
ye4241:feat/permission-mode-selector
Open

feat: replace YoloToggle with PermissionModeSelector for Claude sessions#529
ye4241 wants to merge 10 commits intotiann:mainfrom
ye4241:feat/permission-mode-selector

Conversation

@ye4241
Copy link
Copy Markdown

@ye4241 ye4241 commented Apr 26, 2026

Problem

The new session UI only offers a binary Yolo toggle, which maps to bypassPermissions. Users have no way to select other Claude permission modes (acceptEdits, plan, default) when spawning a remote session.

Solution

Replace YoloToggle with a PermissionModeSelector dropdown that exposes all four Claude permission modes using the existing CLAUDE_PERMISSION_MODES and PERMISSION_MODE_LABELS already defined in @hapi/protocol.

Mode Label Description
default Default Normal mode, confirms dangerous operations
acceptEdits Accept Edits Auto-accepts file edits, confirms shell commands
bypassPermissions Yolo Skips all permission checks
plan Plan Mode Planning only, no execution

Changes

  • PermissionModeSelector.tsx — new dropdown component (only shown for Claude agent, same pattern as ClaudeEffortSelector)
  • index.tsx — replace yoloMode: boolean state with permissionMode: ClaudePermissionMode; derive yolo: true when permissionMode === 'bypassPermissions' for runner backwards compatibility
  • preferences.ts — persist selection in localStorage; migrate users who had yolo=true to bypassPermissions
  • useSpawnSession.ts / client.ts — thread permissionMode through to the spawn API call
  • en.ts / zh-CN.ts — add newSession.permissionMode i18n key

Test plan

  • Start a new Claude session with each permission mode and verify the runner receives the correct permissionMode in logs
  • Verify existing users with yolo=true in localStorage are migrated to bypassPermissions
  • Verify selector is hidden for non-Claude agents (codex, gemini, etc.)

ye4241 added 2 commits April 26, 2026 20:02
…de default

When hapi spawns a session without an explicit permissionMode, it was
hardcoded to 'default', ignoring the user's `permissions.defaultMode`
setting in ~/.claude/settings.json (e.g. 'auto').

Now reads Claude's settings.json via the existing readClaudeSettings()
utility and uses its defaultMode as the fallback, keeping 'default' as
the final fallback for users without the setting configured.
The previous UI only offered a binary yolo toggle (bypassPermissions).
This replaces it with a proper dropdown selector exposing all four Claude
permission modes: Default, Accept Edits, Bypass Permissions (yolo), and
Plan Mode.

Changes:
- Add PermissionModeSelector component using existing CLAUDE_PERMISSION_MODES
  and PERMISSION_MODE_LABELS from @hapi/protocol
- Remove YoloToggle from NewSession; derive yolo flag from permissionMode
  === 'bypassPermissions' for backwards compatibility with the runner
- Thread permissionMode through useSpawnSession and ApiClient.spawnSession
  so the backend receives it directly
- Persist selected mode in localStorage with migration from legacy yolo key
- Add i18n keys for en and zh-CN locales
Copilot AI review requested due to automatic review settings April 26, 2026 12:40
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] permissionMode is dropped by the spawn API before it reaches the runner. The web client now sends permissionMode, but hub/src/web/routes/machines.ts still omits it from spawnBodySchema and from the engine.spawnSession(...) call, so Zod strips the field and acceptEdits / plan never reach rpcGateway or buildCliArgs. Evidence: web/src/api/client.ts:422, hub/src/web/routes/machines.ts:7.
    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
    )
  • [Major] Hidden Claude bypass selection still sends yolo: true for non-Claude agents. PermissionModeSelector returns null unless agent === 'claude', but handleCreate derives yolo from the stored permissionMode without checking the current agent. A user who previously saved bypassPermissions for Claude can switch to Codex/Gemini/Cursor/OpenCode and spawn with --yolo despite no visible control. Evidence: web/src/components/NewSession/index.tsx:292.
    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
    })

Summary

  • Review mode: initial
  • The current diff does not yet deliver the new Claude permission modes end-to-end, and it introduces a hidden unsafe-mode carryover risk for non-Claude spawns.
  • Coverage gap: no updated NewSession preference/spawn tests for migration, permissionMode payload forwarding, or non-Claude yolo gating.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/api/client.ts
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
)

Comment thread web/src/components/NewSession/index.tsx Outdated
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
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Replaces the binary YOLO toggle in the New Session UI with a Claude-only Permission Mode selector so users can choose among all supported Claude permission modes, and attempts to thread that selection through to the spawn request.

Changes:

  • Added PermissionModeSelector dropdown (Claude-only) and wired it into NewSession.
  • Persisted permissionMode in localStorage with migration from legacy yolo=true.
  • Added permissionMode to the web spawn call path and introduced a new i18n label.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
web/src/components/NewSession/PermissionModeSelector.tsx New Claude-only dropdown UI for selecting permission mode.
web/src/components/NewSession/index.tsx Replaces yoloMode state with permissionMode and sends both yolo (compat) + permissionMode on spawn.
web/src/components/NewSession/preferences.ts Adds localStorage persistence + migration from legacy YOLO toggle.
web/src/components/NewSession/types.ts Exposes Claude permission mode option list from protocol constants/labels.
web/src/hooks/mutations/useSpawnSession.ts Threads permissionMode through the mutation to the API client.
web/src/api/client.ts Adds permissionMode to spawnSession request body.
web/src/lib/locales/en.ts Adds newSession.permissionMode label.
web/src/lib/locales/zh-CN.ts Adds newSession.permissionMode label.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread web/src/api/client.ts
Comment on lines 420 to 423
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

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.
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.
Comment on lines +46 to +60
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') {
return 'bypassPermissions'
}
} catch {
// Ignore storage errors
}
return 'default'
}
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.
Comment thread web/src/components/NewSession/index.tsx Outdated
effort: resolvedEffort,
modelReasoningEffort: resolvedModelReasoningEffort,
yolo: yoloMode,
yolo: permissionMode === 'bypassPermissions',
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

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] permissionMode is still dropped before it reaches the runner. The web client now serializes permissionMode, but hub/src/web/routes/machines.ts still omits it from spawnBodySchema and from engine.spawnSession(...), so Zod strips the field and acceptEdits / plan never reach rpcGateway or buildCliArgs. Evidence: web/src/api/client.ts:422, hub/src/web/routes/machines.ts:7.
    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
    )
  • [Major] Hidden Claude bypass state still sends yolo: true for non-Claude agents. PermissionModeSelector is hidden unless agent === 'claude', but handleCreate derives yolo from the persisted Claude permissionMode without checking the selected agent. A user who saved bypassPermissions for Claude can switch to Codex/Gemini/Cursor/OpenCode and spawn with unsafe mode despite no visible control. Evidence: web/src/components/NewSession/index.tsx:292.
    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
    })

Summary

  • Review mode: follow-up after new commits
  • The follow-up commit adds Claude settings default-mode handling, but the end-to-end web spawn path still does not deliver the new permission modes, and the hidden non-Claude unsafe-mode carryover remains.
  • Coverage gap: no updated NewSession preference/spawn tests for migration, permissionMode payload forwarding, or non-Claude yolo gating.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/api/client.ts
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.

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 thread web/src/components/NewSession/index.tsx Outdated
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 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
})

- Validate claudeDefaultMode from Claude settings with PermissionModeSchema
  instead of unsafe type cast
- Fix localStorage migration to persist bypassPermissions to new key
- Add permissionMode field to hub spawn endpoint schema and pass it through
  to syncEngine.spawnSession
- Delete dead YoloToggle.tsx component
- Remove unused newSession.yolo i18n keys from en.ts and zh-CN.ts
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Hidden Claude bypass state still sets YOLO for non-Claude spawns — PermissionModeSelector is hidden unless agent === 'claude', but handleCreate still derives yolo from the persisted Claude permissionMode before checking the selected agent. A user who saved bypassPermissions for Claude can switch to Codex/Gemini/Cursor/OpenCode and spawn with unsafe mode even though no control is visible. Evidence: web/src/components/NewSession/index.tsx:292.
    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
    })
  • [Minor] permissionMode bypasses runtime validation in the spawn API — the route accepts any string and casts it to PermissionMode, so invalid values silently reach the runner path, and globally valid but wrong-for-agent modes can be forwarded to CLI commands that reject them later. This should be rejected at the API boundary like the existing session permission-mode route. Evidence: hub/src/web/routes/machines.ts:15.
    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)
    }

Summary

  • Review mode: follow-up after new commits
  • The follow-up wires permissionMode through the hub, but the hidden non-Claude unsafe-mode carryover remains, and the new API field is cast instead of validated.
  • Coverage gap: no updated web/hub tests cover permission-mode spawn payloads, legacy yolo migration, or non-Claude yolo gating.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/components/NewSession/index.tsx Outdated
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] 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
})

Comment thread hub/src/web/routes/machines.ts Outdated
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)
}

- runClaude.ts: use PermissionModeSchema.safeParse().data directly to
  eliminate intermediate variable and unsafe cast; add
  isPermissionModeAllowedForFlavor check so cross-agent modes (e.g.
  codex yolo) in ~/.claude/settings.json are silently ignored
- machines.ts: replace z.string().optional() + type cast with
  PermissionModeSchema.optional() from @hapi/protocol/schemas,
  matching the pattern used in sessions.ts
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Hidden Claude bypass state still sets YOLO for non-Claude spawns — PermissionModeSelector returns null unless agent === 'claude', and permissionMode is already suppressed for other agents, but the payload still sends yolo: true whenever the persisted Claude mode is bypassPermissions. A user can save bypass mode for Claude, switch to Codex/Gemini/Cursor/OpenCode, and spawn unsafe mode with no visible control. Evidence: web/src/components/NewSession/index.tsx:292.
    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
    })
  • [Minor] Spawn API accepts permission modes that are invalid for the requested agent — PermissionModeSchema accepts Claude-only values like bypassPermissions; with agent: 'codex', this reaches buildCliArgs() as --permission-mode bypassPermissions, then the spawned CLI rejects it later instead of the API returning a 400 at the boundary. Evidence: hub/src/web/routes/machines.ts:67.
    Suggested fix:

    import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'
    
    const agent = parsed.data.agent ?? 'claude'
    if (parsed.data.permissionMode && !isPermissionModeAllowedForFlavor(parsed.data.permissionMode, agent)) {
        return c.json({ error: 'Invalid permissionMode' }, 400)
    }
    
    const result = await engine.spawnSession(
        machineId,
        parsed.data.directory,
        agent,
        parsed.data.model,
        parsed.data.modelReasoningEffort,
        parsed.data.yolo,
        parsed.data.sessionType,
        parsed.data.worktreeName,
        undefined,
        parsed.data.effort,
        parsed.data.permissionMode
    )

Summary

  • Review mode: follow-up after new commits
  • The follow-up fixed raw permissionMode schema validation, but the hidden non-Claude YOLO carryover remains, and the route still does not validate permissionMode against the requested agent flavor.
  • Coverage gap: no updated web/hub tests cover permission-mode spawn payloads, legacy yolo migration, or non-Claude yolo gating.

Testing

  • Not run (automation)

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Minor] Spawn API accepts permission modes that are invalid for the selected agent — PermissionModeSchema only proves the value is in the global union, but this route forwards it without checking the requested/default agent flavor. buildCliArgs() emits --permission-mode for any global mode, while the individual CLI commands reject modes outside their own flavor set, so a request such as agent: "codex", permissionMode: "bypassPermissions" fails later in the runner instead of returning a clean 400 at the API boundary. Evidence: hub/src/web/routes/machines.ts:67.
    Suggested fix:
    import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'
    
    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,
        agent,
        parsed.data.model,
        parsed.data.modelReasoningEffort,
        parsed.data.yolo,
        parsed.data.sessionType,
        parsed.data.worktreeName,
        undefined,
        parsed.data.effort,
        parsed.data.permissionMode
    )

Summary

  • Review mode: follow-up after new commits
  • Latest commit fixed the hidden non-Claude yolo carryover from the prior bot pass.
  • One boundary-validation issue remains on the new permissionMode spawn API field.
  • Coverage gap: no hub/web tests cover permissionMode spawn validation, legacy YOLO migration, or Claude-only payload gating.

Testing

  • Not run (automation)

HAPI Bot

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)
}

@ye4241
Copy link
Copy Markdown
Author

ye4241 commented Apr 26, 2026

The test failure in aliveEvents.test.ts > marks session thinking immediately when a user message is accepted by the hub is a pre-existing flaky test unrelated to this PR — the same test failed on main in run 24945786690 before any of our changes.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Non-Claude YOLO spawn option was removed — the old YoloToggle was rendered for every agent and yoloMode was forwarded to spawnSession, which lets Codex/Gemini/Cursor/OpenCode start with their supported --yolo mode. The new code drops both yolo and permissionMode whenever agent !== 'claude', so those agents can no longer be started in YOLO from the web UI. Evidence: web/src/components/NewSession/index.tsx:285.
    Suggested fix:
    const nonClaudeYolo = agent !== 'claude' && yoloMode
    const claudePermissionMode = agent === 'claude' ? permissionMode : undefined
    
    // render the Claude selector only for Claude, and keep a YOLO toggle for other agents
    {agent === 'claude' ? (
        <PermissionModeSelector
            agent={agent}
            permissionMode={permissionMode}
            isDisabled={isFormDisabled}
            onPermissionModeChange={setPermissionMode}
        />
    ) : (
        <YoloToggle
            yoloMode={yoloMode}
            isDisabled={isFormDisabled}
            onToggle={setYoloMode}
        />
    )}
    
    await spawnSession({
        ...,
        yolo: claudePermissionMode === 'bypassPermissions' || nonClaudeYolo,
        permissionMode: claudePermissionMode,
    })

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One regression remains in the new-session web flow: non-Claude agents lost the existing YOLO spawn control.
  • Coverage gap: no web/hub tests cover agent-specific spawn payloads or the legacy YOLO migration path.

Testing

  • Not run (automation)

HAPI Bot

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,
})

…MODES

Claude CLI supports --permission-mode auto and dontAsk but hapi's protocol
did not include them, causing auto mode from ~/.claude/settings.json to be
silently dropped and fall back to default.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Non-Claude YOLO spawn option is no longer reachable — the removed YoloToggle applied to every agent and handleCreate forwarded yolo directly, but the new flow only sets yolo when the Claude-only permissionMode is bypassPermissions. Because PermissionModeSelector returns null for non-Claude agents, Codex/Cursor/Gemini/OpenCode sessions can no longer be spawned in YOLO from the web UI even though their CLI commands still support it. Evidence: web/src/components/NewSession/index.tsx:293.
    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,
    })

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous bot finding remains unresolved in the latest head: non-Claude agents lost the existing YOLO spawn control.
  • Coverage gap: no web/hub test asserts agent-specific spawn payloads or the legacy YOLO preference migration.

Testing

  • Not run (automation; review only, no PR code executed)

HAPI Bot

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

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Non-Claude YOLO spawn option is no longer reachable — PermissionModeSelector renders only for Claude, but the spawn payload now sets yolo only when the Claude permission mode is bypassPermissions. The removed YoloToggle was available for all agents, so Codex/Cursor/Gemini/OpenCode sessions no longer have a web UI path to spawn with their supported YOLO mode. Evidence: web/src/components/NewSession/index.tsx:293.
    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,
    })

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous bot finding remains unresolved in the latest head. The only change since that pass is bun.lock, so the functional regression is unchanged.
  • Coverage gap: no web test asserts the per-agent spawn payload after replacing the global YOLO toggle.

Testing

  • Not run (automation; review only, no PR code executed)

HAPI Bot

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 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,
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants