mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(desktop): add profile-aware approval mode control
This commit is contained in:
parent
dfeedf613d
commit
3510b18814
23 changed files with 713 additions and 74 deletions
|
|
@ -313,6 +313,7 @@ export function useGatewayBoot({
|
|||
|
||||
applyDesktopBootProgress(payload)
|
||||
})
|
||||
|
||||
void desktop
|
||||
.getBootProgress()
|
||||
.then(snapshot => applyDesktopBootProgress(snapshot))
|
||||
|
|
@ -356,7 +357,8 @@ export function useGatewayBoot({
|
|||
}
|
||||
})
|
||||
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
|
||||
const sourceProfile = normalizeProfileKey($activeGatewayProfile.get())
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }))
|
||||
|
||||
// Wake signals: power resume (macOS/Windows), network coming back, and the
|
||||
// window regaining focus/visibility. Each nudges an immediate reconnect.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const ACTIVE_SID = 'session-active'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
|
||||
describe('live session.info approval mode reconciliation', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
$approvalModes.set({})
|
||||
$activeGatewayProfile.set('work')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reconciles an active-session event under its source gateway profile', async () => {
|
||||
await mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
payload: { approval_mode: 'off' },
|
||||
profile: 'work',
|
||||
session_id: ACTIVE_SID,
|
||||
type: 'session.info'
|
||||
})
|
||||
)
|
||||
|
||||
expect(approvalModeForProfile('work')).toBe('off')
|
||||
expect(approvalModeForProfile('default')).toBe('smart')
|
||||
})
|
||||
|
||||
it('ignores stale session.info from a non-active session on the active gateway', async () => {
|
||||
await mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
payload: { approval_mode: 'off' },
|
||||
profile: 'work',
|
||||
session_id: 'session-stale',
|
||||
type: 'session.info'
|
||||
})
|
||||
)
|
||||
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
})
|
||||
|
||||
it('does not cache an event under a different active profile when its source profile is absent', async () => {
|
||||
await mountStream()
|
||||
$activeGatewayProfile.set('personal')
|
||||
|
||||
act(() =>
|
||||
handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' })
|
||||
)
|
||||
|
||||
expect(approvalModeForProfile('personal')).toBe('smart')
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
})
|
||||
})
|
||||
|
|
@ -12,6 +12,7 @@ import { playCompletionSound } from '@/lib/completion-sound'
|
|||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
|
|
@ -20,6 +21,7 @@ import { dispatchNativeNotification } from '@/store/native-notifications'
|
|||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { followActiveSessionCwd } from '@/store/projects'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
|
|
@ -117,6 +119,20 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
const providerChanged = typeof payload?.provider === 'string'
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
// Config is profile-scoped, but session.info also arrives for background
|
||||
// sessions. Only an active-session event from the currently active
|
||||
// gateway may reconcile the foreground cache. Requiring the renderer's
|
||||
// source tag prevents an event queued before a profile swap from being
|
||||
// attributed to the newly active profile.
|
||||
if (
|
||||
isActiveEvent &&
|
||||
typeof payload?.approval_mode === 'string' &&
|
||||
event.profile &&
|
||||
normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())
|
||||
) {
|
||||
reconcileApprovalModeForProfile(event.profile, payload.approval_mode)
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
applyRuntimeInfo,
|
||||
chatMessageArraysEquivalent,
|
||||
isSessionGoneError,
|
||||
reconcileResumeMessages,
|
||||
|
|
@ -17,6 +20,20 @@ const msg = (id: string, role: ChatMessage['role'], text: string, extra: Partial
|
|||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => over as SessionInfo
|
||||
|
||||
describe('applyRuntimeInfo approval mode', () => {
|
||||
beforeEach(() => {
|
||||
$approvalModes.set({})
|
||||
$activeGatewayProfile.set('work')
|
||||
})
|
||||
|
||||
it('reconciles session.info against the gateway profile', () => {
|
||||
applyRuntimeInfo({ approval_mode: 'smart', desktop_contract: 3 })
|
||||
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
expect(approvalModeForProfile('default')).toBe('smart')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSessionGoneError', () => {
|
||||
it('is true for 404 / session-not-found, false otherwise', () => {
|
||||
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { getSession } from '@/hermes'
|
|||
import { type ChatMessage, chatMessageText } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
|
|
@ -266,6 +267,10 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
|
|||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
if (info.approval_mode !== undefined) {
|
||||
reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode)
|
||||
}
|
||||
|
||||
if (info.credential_warning) {
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
|
|
|||
89
apps/desktop/src/app/shell/approval-mode-menu.test.tsx
Normal file
89
apps/desktop/src/app/shell/approval-mode-menu.test.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StatusbarControls } from '@/app/shell/statusbar-controls'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { $approvalModes } from '@/store/approval-mode'
|
||||
|
||||
import { useApprovalModeStatusbarItem } from './approval-mode-menu'
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
HTMLElement.prototype.scrollIntoView ??= () => undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$approvalModes.set({})
|
||||
})
|
||||
|
||||
function Harness({
|
||||
profile = 'default',
|
||||
requestGateway
|
||||
}: {
|
||||
profile?: string
|
||||
requestGateway: (method: string, params?: Record<string, unknown>) => Promise<unknown>
|
||||
}) {
|
||||
const item = useApprovalModeStatusbarItem(profile, requestGateway)
|
||||
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<StatusbarControls items={[item]} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('approval mode statusbar item', () => {
|
||||
it('uses the shared statusbar menu trigger without a nested bespoke button', async () => {
|
||||
const response = new Promise<never>(() => undefined)
|
||||
render(<Harness requestGateway={vi.fn(() => response)} />)
|
||||
|
||||
const statusbar = screen.getByRole('contentinfo')
|
||||
const trigger = within(statusbar).getByRole('button', { name: /smart/i })
|
||||
expect(within(statusbar).getAllByRole('button')).toHaveLength(1)
|
||||
|
||||
fireEvent.pointerDown(trigger, { button: 0 })
|
||||
|
||||
expect(await screen.findByRole('menuitemradio', { name: /manual/i })).toBeTruthy()
|
||||
expect(trigger.getAttribute('aria-haspopup')).toBe('menu')
|
||||
expect(screen.getByRole('menuitemradio', { name: /smart/i })).toBeTruthy()
|
||||
expect(screen.getByRole('menuitemradio', { name: /off/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('writes the selected mode through the gateway and updates its shared trigger label', async () => {
|
||||
const requestGateway = vi.fn(async (_method, params) => ({ value: params?.value ?? 'smart' }))
|
||||
render(<Harness profile="work" requestGateway={requestGateway} />)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: /smart/i }), { button: 0 })
|
||||
fireEvent.click(await screen.findByRole('menuitemradio', { name: /manual/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'approvals.mode', value: 'manual' })
|
||||
expect(screen.getByRole('button', { name: /manual/i })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the shared trigger and menu in the active locale', async () => {
|
||||
const response = new Promise<never>(() => undefined)
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="ja">
|
||||
<Harness requestGateway={vi.fn(() => response)} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'スマート' }), { button: 0 })
|
||||
|
||||
expect(await screen.findByText('必要な場合にのみ確認します')).toBeTruthy()
|
||||
expect(screen.getByText('承認プロンプトなしで実行します')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
81
apps/desktop/src/app/shell/approval-mode-menu.tsx
Normal file
81
apps/desktop/src/app/shell/approval-mode-menu.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import type { StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Zap, ZapFilled } from '@/lib/icons'
|
||||
import {
|
||||
$approvalModes,
|
||||
type ApprovalMode,
|
||||
type ApprovalModeRequester,
|
||||
setApprovalModeForProfile,
|
||||
syncApprovalModeForProfile
|
||||
} from '@/store/approval-mode'
|
||||
|
||||
export function useApprovalModeStatusbarItem(
|
||||
profile: string,
|
||||
requestGateway: ApprovalModeRequester
|
||||
): StatusbarItem {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.approvalMode
|
||||
const modes = useStore($approvalModes)
|
||||
const mode = modes[profile.trim() || 'default'] ?? 'smart'
|
||||
|
||||
const labels = useMemo<Record<ApprovalMode, string>>(
|
||||
() => ({ manual: copy.manual, smart: copy.smart, off: copy.off }),
|
||||
[copy.manual, copy.off, copy.smart]
|
||||
)
|
||||
|
||||
const descriptions = useMemo<Record<ApprovalMode, string>>(
|
||||
() => ({
|
||||
manual: copy.manualDescription,
|
||||
smart: copy.smartDescription,
|
||||
off: copy.offDescription
|
||||
}),
|
||||
[copy.manualDescription, copy.offDescription, copy.smartDescription]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void syncApprovalModeForProfile(requestGateway, profile).catch(() => undefined)
|
||||
}, [profile, requestGateway])
|
||||
|
||||
return {
|
||||
className: mode === 'off' ? 'bg-(--chrome-action-hover) text-foreground' : undefined,
|
||||
icon: mode === 'off' ? <ZapFilled className="size-3.5" /> : <Zap className="size-3.5 opacity-70" />,
|
||||
id: 'approval-mode',
|
||||
label: labels[mode],
|
||||
menuAlign: 'end',
|
||||
menuClassName: 'w-72 p-1',
|
||||
menuContent: (
|
||||
<>
|
||||
<DropdownMenuLabel>{copy.title}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={value => {
|
||||
void setApprovalModeForProfile(requestGateway, profile, value as ApprovalMode).catch(() => undefined)
|
||||
}}
|
||||
value={mode}
|
||||
>
|
||||
{(['manual', 'smart', 'off'] as const).map(value => (
|
||||
<DropdownMenuRadioItem className="items-start gap-2" key={value} value={value}>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs text-foreground">{labels[value]}</span>
|
||||
<span className="text-[0.6875rem] leading-snug text-(--ui-text-tertiary)">
|
||||
{descriptions[value]}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
),
|
||||
title: copy.ariaLabel(labels[mode]),
|
||||
variant: 'menu'
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,21 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu'
|
||||
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal, Zap, ZapFilled } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
|
||||
import { copyFilePath, revealFile } from '@/store/file-actions'
|
||||
import { revealFileInTree } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
|
|
@ -23,8 +24,6 @@ import {
|
|||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$yoloActive,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
|
||||
import { $gatewayRestarting } from '@/store/system-actions'
|
||||
|
|
@ -39,7 +38,7 @@ import {
|
|||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
|
||||
import type { StatusbarItem } from '../statusbar-controls'
|
||||
|
||||
function workspaceLabel(cwd: string): string {
|
||||
const normalized = cwd.replace(/[\\/]+$/, '')
|
||||
|
|
@ -74,7 +73,6 @@ export function useStatusbarItems({
|
|||
inferenceStatus,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
freshDraftReady,
|
||||
requestGateway,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
|
|
@ -83,8 +81,8 @@ export function useStatusbarItems({
|
|||
const copy = t.shell.statusbar
|
||||
const fileMenu = t.fileMenu
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const yoloActive = useStore($yoloActive)
|
||||
const busy = useStore($busy)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
|
|
@ -101,45 +99,8 @@ export function useStatusbarItems({
|
|||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
|
||||
|
||||
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
|
||||
// new-chat draft (no runtime session yet) we arm locally; the session-create
|
||||
// path applies it once the backend session exists.
|
||||
//
|
||||
// Shift+click flips the GLOBAL approvals.mode instead — a persistent,
|
||||
// all-sessions/CLI/TUI/cron bypass that survives restarts.
|
||||
const toggleYolo = useCallback(
|
||||
async (modifiers?: StatusbarSelectModifiers) => {
|
||||
const next = !$yoloActive.get()
|
||||
|
||||
setYoloActive(next)
|
||||
|
||||
if (modifiers?.shiftKey) {
|
||||
try {
|
||||
await setGlobalYolo(requestGateway, next)
|
||||
} catch {
|
||||
setYoloActive(!next)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sid = $activeSessionId.get()
|
||||
|
||||
if (!sid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionYolo(requestGateway, sid, next)
|
||||
} catch {
|
||||
setYoloActive(!next)
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
|
||||
|
||||
const gatewayMenuContent = useMemo(
|
||||
() => (close: () => void) => (
|
||||
|
|
@ -429,17 +390,8 @@ export function useStatusbarItems({
|
|||
variant: 'text'
|
||||
},
|
||||
{
|
||||
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
|
||||
hidden: !showYoloToggle,
|
||||
icon: yoloActive ? (
|
||||
<ZapFilled className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<Zap className="size-3.5 shrink-0 opacity-70" />
|
||||
),
|
||||
id: 'yolo',
|
||||
onSelect: modifiers => void toggleYolo(modifiers),
|
||||
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||
variant: 'action'
|
||||
...approvalModeItem,
|
||||
hidden: gatewayState !== 'open',
|
||||
},
|
||||
{
|
||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
|
|
@ -455,6 +407,7 @@ export function useStatusbarItems({
|
|||
],
|
||||
[
|
||||
activeSessionId,
|
||||
approvalModeItem,
|
||||
backendVersionItem,
|
||||
busy,
|
||||
chatOpen,
|
||||
|
|
@ -465,11 +418,9 @@ export function useStatusbarItems({
|
|||
currentUsage,
|
||||
requestGateway,
|
||||
sessionStartedAt,
|
||||
showYoloToggle,
|
||||
gatewayState,
|
||||
terminalTakeover,
|
||||
toggleYolo,
|
||||
turnStartedAt,
|
||||
yoloActive
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2068,6 +2068,16 @@ export const en: Translations = {
|
|||
viewAllLogs: 'View all logs →',
|
||||
messagingPlatforms: 'Messaging platforms'
|
||||
},
|
||||
approvalMode: {
|
||||
title: 'Approval mode',
|
||||
ariaLabel: mode => `Approval mode: ${mode}`,
|
||||
manual: 'Manual',
|
||||
manualDescription: 'Ask before actions that require approval',
|
||||
smart: 'Smart',
|
||||
smartDescription: 'Automatically assess actions and ask when needed',
|
||||
off: 'Off',
|
||||
offDescription: 'Run without approval prompts'
|
||||
},
|
||||
statusbar: {
|
||||
unknown: 'unknown',
|
||||
restart: 'restart',
|
||||
|
|
|
|||
|
|
@ -2009,6 +2009,16 @@ export const ja = defineLocale({
|
|||
viewAllLogs: 'すべてのログを見る →',
|
||||
messagingPlatforms: 'メッセージングプラットフォーム'
|
||||
},
|
||||
approvalMode: {
|
||||
title: '承認モード',
|
||||
ariaLabel: mode => `承認モード: ${mode}`,
|
||||
manual: '手動',
|
||||
manualDescription: '承認が必要な操作の前に確認します',
|
||||
smart: 'スマート',
|
||||
smartDescription: '必要な場合にのみ確認します',
|
||||
off: 'オフ',
|
||||
offDescription: '承認プロンプトなしで実行します'
|
||||
},
|
||||
statusbar: {
|
||||
unknown: '不明',
|
||||
restart: '再起動',
|
||||
|
|
|
|||
|
|
@ -1699,6 +1699,16 @@ export interface Translations {
|
|||
viewAllLogs: string
|
||||
messagingPlatforms: string
|
||||
}
|
||||
approvalMode: {
|
||||
title: string
|
||||
ariaLabel: (mode: string) => string
|
||||
manual: string
|
||||
manualDescription: string
|
||||
smart: string
|
||||
smartDescription: string
|
||||
off: string
|
||||
offDescription: string
|
||||
}
|
||||
statusbar: {
|
||||
unknown: string
|
||||
restart: string
|
||||
|
|
|
|||
|
|
@ -1945,6 +1945,16 @@ export const zhHant = defineLocale({
|
|||
viewAllLogs: '查看全部記錄 →',
|
||||
messagingPlatforms: '訊息平台'
|
||||
},
|
||||
approvalMode: {
|
||||
title: '核准模式',
|
||||
ariaLabel: mode => `核准模式:${mode}`,
|
||||
manual: '手動',
|
||||
manualDescription: '執行需要核准的操作前詢問',
|
||||
smart: '智慧',
|
||||
smartDescription: '自動評估操作,並在需要時詢問',
|
||||
off: '關閉',
|
||||
offDescription: '不顯示核准提示,直接執行'
|
||||
},
|
||||
statusbar: {
|
||||
unknown: '未知',
|
||||
restart: '重新啟動',
|
||||
|
|
|
|||
|
|
@ -2232,6 +2232,16 @@ export const zh: Translations = {
|
|||
viewAllLogs: '查看全部日志 →',
|
||||
messagingPlatforms: '消息平台'
|
||||
},
|
||||
approvalMode: {
|
||||
title: '审批模式',
|
||||
ariaLabel: mode => `审批模式:${mode}`,
|
||||
manual: '手动',
|
||||
manualDescription: '执行需要审批的操作前询问',
|
||||
smart: '智能',
|
||||
smartDescription: '自动评估操作,并在需要时询问',
|
||||
off: '关闭',
|
||||
offDescription: '不显示审批提示,直接运行'
|
||||
},
|
||||
statusbar: {
|
||||
unknown: '未知',
|
||||
restart: '重启',
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export type GatewayEventPayload = {
|
|||
reasoning_effort?: string
|
||||
service_tier?: string
|
||||
fast?: boolean
|
||||
approval_mode?: string
|
||||
yolo?: boolean
|
||||
running?: boolean
|
||||
cwd?: string
|
||||
|
|
|
|||
95
apps/desktop/src/store/approval-mode.test.ts
Normal file
95
apps/desktop/src/store/approval-mode.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
$approvalModes,
|
||||
approvalModeForProfile,
|
||||
reconcileApprovalModeForProfile,
|
||||
setApprovalModeForProfile,
|
||||
syncApprovalModeForProfile
|
||||
} from './approval-mode'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
describe('profile-scoped approval mode cache', () => {
|
||||
beforeEach(() => $approvalModes.set({}))
|
||||
|
||||
it('labels an unread profile Smart by default and adopts backend truth', async () => {
|
||||
expect(approvalModeForProfile('default')).toBe('smart')
|
||||
|
||||
const request = vi.fn(async () => ({ value: 'manual' }))
|
||||
await syncApprovalModeForProfile(request, 'default')
|
||||
|
||||
expect(request).toHaveBeenCalledWith('config.get', { key: 'approvals.mode' })
|
||||
expect(approvalModeForProfile('default')).toBe('manual')
|
||||
})
|
||||
|
||||
it('keeps profile values isolated', async () => {
|
||||
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'manual' })), 'work')
|
||||
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'off' })), 'personal')
|
||||
|
||||
expect(approvalModeForProfile('work')).toBe('manual')
|
||||
expect(approvalModeForProfile('personal')).toBe('off')
|
||||
expect(approvalModeForProfile('default')).toBe('smart')
|
||||
})
|
||||
|
||||
it('rolls consecutive failed writes back to the last authoritative value', async () => {
|
||||
await syncApprovalModeForProfile(vi.fn(async () => ({ value: 'smart' })), 'default')
|
||||
const first = deferred<{ value: string }>()
|
||||
const second = deferred<{ value: string }>()
|
||||
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => second.promise)
|
||||
|
||||
const staleWrite = setApprovalModeForProfile(request, 'default', 'manual')
|
||||
const currentWrite = setApprovalModeForProfile(request, 'default', 'off')
|
||||
expect(approvalModeForProfile('default')).toBe('off')
|
||||
|
||||
first.reject(new Error('old failure'))
|
||||
await expect(staleWrite).rejects.toThrow('old failure')
|
||||
expect(approvalModeForProfile('default')).toBe('off')
|
||||
|
||||
second.reject(new Error('current failure'))
|
||||
await expect(currentWrite).rejects.toThrow('current failure')
|
||||
expect(approvalModeForProfile('default')).toBe('smart')
|
||||
})
|
||||
|
||||
it('lets a backend event supersede an optimistic write and its later failure', async () => {
|
||||
const write = deferred<{ value: string }>()
|
||||
const pending = setApprovalModeForProfile(vi.fn(() => write.promise), 'work', 'off')
|
||||
|
||||
reconcileApprovalModeForProfile('work', 'smart')
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
|
||||
write.reject(new Error('late failure'))
|
||||
await expect(pending).rejects.toThrow('late failure')
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
})
|
||||
|
||||
it('ignores a stale initial read after a newer write succeeds', async () => {
|
||||
const read = deferred<{ value: string }>()
|
||||
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => read.promise)
|
||||
.mockResolvedValueOnce({ value: 'off' })
|
||||
|
||||
const staleRead = syncApprovalModeForProfile(request, 'default')
|
||||
await setApprovalModeForProfile(request, 'default', 'off')
|
||||
read.resolve({ value: 'manual' })
|
||||
await staleRead
|
||||
|
||||
expect(approvalModeForProfile('default')).toBe('off')
|
||||
})
|
||||
})
|
||||
100
apps/desktop/src/store/approval-mode.ts
Normal file
100
apps/desktop/src/store/approval-mode.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export type ApprovalMode = 'manual' | 'off' | 'smart'
|
||||
export type ApprovalModeRequester = (
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
) => Promise<unknown>
|
||||
|
||||
const APPROVAL_MODES = new Set<ApprovalMode>(['manual', 'smart', 'off'])
|
||||
const revisions = new Map<string, number>()
|
||||
const confirmedModes = new Map<string, ApprovalMode>()
|
||||
|
||||
export const $approvalModes = atom<Record<string, ApprovalMode>>({})
|
||||
|
||||
function profileKey(profile: string): string {
|
||||
return profile.trim() || 'default'
|
||||
}
|
||||
|
||||
function nextRevision(profile: string): number {
|
||||
const revision = (revisions.get(profile) ?? 0) + 1
|
||||
revisions.set(profile, revision)
|
||||
|
||||
return revision
|
||||
}
|
||||
|
||||
function normalizeApprovalMode(value: unknown): ApprovalMode {
|
||||
const normalized = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase() as ApprovalMode
|
||||
|
||||
return APPROVAL_MODES.has(normalized) ? normalized : 'manual'
|
||||
}
|
||||
|
||||
export function approvalModeForProfile(profile: string): ApprovalMode {
|
||||
return $approvalModes.get()[profileKey(profile)] ?? 'smart'
|
||||
}
|
||||
|
||||
function cacheApprovalMode(profile: string, mode: ApprovalMode): void {
|
||||
const key = profileKey(profile)
|
||||
$approvalModes.set({ ...$approvalModes.get(), [key]: mode })
|
||||
}
|
||||
|
||||
export function reconcileApprovalModeForProfile(profile: string, value: unknown): ApprovalMode {
|
||||
const key = profileKey(profile)
|
||||
const mode = normalizeApprovalMode(value)
|
||||
nextRevision(key)
|
||||
confirmedModes.set(key, mode)
|
||||
cacheApprovalMode(key, mode)
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export async function syncApprovalModeForProfile(
|
||||
requestGateway: ApprovalModeRequester,
|
||||
profile: string
|
||||
): Promise<ApprovalMode> {
|
||||
const key = profileKey(profile)
|
||||
const revision = nextRevision(key)
|
||||
const result = (await requestGateway('config.get', { key: 'approvals.mode' })) as { value?: string }
|
||||
const mode = normalizeApprovalMode(result?.value)
|
||||
|
||||
if (revisions.get(key) === revision) {
|
||||
confirmedModes.set(key, mode)
|
||||
cacheApprovalMode(key, mode)
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export async function setApprovalModeForProfile(
|
||||
requestGateway: ApprovalModeRequester,
|
||||
profile: string,
|
||||
mode: ApprovalMode
|
||||
): Promise<ApprovalMode> {
|
||||
const key = profileKey(profile)
|
||||
const revision = nextRevision(key)
|
||||
cacheApprovalMode(key, mode)
|
||||
|
||||
try {
|
||||
const result = (await requestGateway('config.set', {
|
||||
key: 'approvals.mode',
|
||||
value: mode
|
||||
})) as { value?: string }
|
||||
|
||||
const authoritative = normalizeApprovalMode(result?.value)
|
||||
|
||||
if (revisions.get(key) === revision) {
|
||||
confirmedModes.set(key, authoritative)
|
||||
cacheApprovalMode(key, authoritative)
|
||||
}
|
||||
|
||||
return authoritative
|
||||
} catch (error) {
|
||||
if (revisions.get(key) === revision) {
|
||||
cacheApprovalMode(key, confirmedModes.get(key) ?? 'smart')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -164,7 +164,7 @@ function createSecondary(profile: string): Secondary {
|
|||
wantOpen: true
|
||||
}
|
||||
|
||||
entry.offEvent = gateway.onEvent(event => config?.onEvent(event))
|
||||
entry.offEvent = gateway.onEvent(event => config?.onEvent({ ...event, profile }))
|
||||
entry.offState = gateway.onState(state => {
|
||||
reportGatewayState(profile, state)
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ describe('reportBackendContract', () => {
|
|||
})
|
||||
|
||||
it('dismisses the toast when the backend meets the contract', () => {
|
||||
reportBackendContract(2)
|
||||
reportBackendContract(3)
|
||||
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -160,8 +160,8 @@ describe('reportBackendContract', () => {
|
|||
lastToast().onDismiss()
|
||||
notifySpy.mockClear()
|
||||
|
||||
reportBackendContract(2) // backend updated → satisfied, snooze cleared
|
||||
reportBackendContract(1) // a later regression must warn immediately
|
||||
reportBackendContract(3) // backend updated → satisfied, snooze cleared
|
||||
reportBackendContract(2) // a later regression must warn immediately
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ function isUpdateToastSnoozed(): boolean {
|
|||
// against. The backend reports its own value in session runtime info; a lower
|
||||
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
|
||||
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
|
||||
const REQUIRED_BACKEND_CONTRACT = 2
|
||||
// v3: requires approvals.mode config RPCs and session.info reconciliation.
|
||||
const REQUIRED_BACKEND_CONTRACT = 3
|
||||
const SKEW_TOAST_ID = 'backend-contract-skew'
|
||||
// The contract check runs on every session.resume (applyRuntimeInfo), so
|
||||
// without a snooze the warning re-popped on every thread the user opened, even
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ export interface PaginatedSessions {
|
|||
|
||||
export interface RpcEvent<T = unknown> {
|
||||
payload?: T
|
||||
profile?: string
|
||||
session_id?: string
|
||||
type: string
|
||||
}
|
||||
|
|
@ -393,6 +394,7 @@ export interface SessionResumeResponse {
|
|||
}
|
||||
|
||||
export interface SessionRuntimeInfo {
|
||||
approval_mode?: 'manual' | 'off' | 'smart'
|
||||
branch?: string
|
||||
config_warning?: string
|
||||
credential_warning?: string
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export type GatewayEventName =
|
|||
|
||||
export interface GatewayEvent<P = unknown> {
|
||||
payload?: P
|
||||
/** Renderer-side source tag added by the Desktop gateway registry. */
|
||||
profile?: string
|
||||
session_id?: string
|
||||
type: GatewayEventName
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2777,6 +2777,95 @@ def test_config_set_yolo_global_scope_writes_approvals_mode(tmp_path, monkeypatc
|
|||
assert yaml.safe_load(cfg_path.read_text())["approvals"]["mode"] == "manual"
|
||||
|
||||
|
||||
def test_config_get_approval_mode_uses_smart_default_when_key_is_missing(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"approvals": {"timeout": 15}})
|
||||
)
|
||||
|
||||
response = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
|
||||
)
|
||||
assert response["result"]["value"] == "smart"
|
||||
|
||||
|
||||
def test_config_get_approval_mode_fails_safe_to_manual_for_invalid_explicit_value(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"approvals": {"mode": "sometimes"}})
|
||||
)
|
||||
|
||||
response = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
|
||||
)
|
||||
assert response["result"]["value"] == "manual"
|
||||
|
||||
|
||||
def test_config_get_approval_mode_normalizes_yaml_off(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump({"approvals": {"mode": False}})
|
||||
)
|
||||
|
||||
response = server.handle_request(
|
||||
{"id": "1", "method": "config.get", "params": {"key": "approvals.mode"}}
|
||||
)
|
||||
assert response["result"]["value"] == "off"
|
||||
|
||||
|
||||
def test_config_set_approval_mode_persists_three_way_value_and_emits_live_status(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
emitted = []
|
||||
monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
|
||||
server._sessions["sid"] = {"agent": object(), "session_key": "profile-session"}
|
||||
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "approvals.mode", "value": "manual"},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
server._sessions.clear()
|
||||
|
||||
assert resp["result"] == {"key": "approvals.mode", "value": "manual"}
|
||||
assert yaml.safe_load((tmp_path / "config.yaml").read_text())["approvals"]["mode"] == "manual"
|
||||
assert emitted and emitted[0][0:2] == ("session.info", "sid")
|
||||
assert emitted[0][2]["approval_mode"] == "manual"
|
||||
|
||||
|
||||
def test_desktop_contract_includes_approval_mode_rpc():
|
||||
assert server.DESKTOP_BACKEND_CONTRACT >= 3
|
||||
|
||||
|
||||
def test_config_set_approval_mode_rejects_unknown_value():
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "approvals.mode", "value": "sometimes"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["error"]["code"] == 4002
|
||||
|
||||
|
||||
def test_config_set_yolo_global_scope_honors_explicit_value(tmp_path, monkeypatch):
|
||||
"""An explicit value pins global approvals.mode regardless of prior state."""
|
||||
import yaml
|
||||
|
|
|
|||
|
|
@ -2486,6 +2486,19 @@ def _write_config_key(key_path: str, value):
|
|||
|
||||
|
||||
_STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
|
||||
_APPROVAL_MODES = frozenset({"manual", "smart", "off"})
|
||||
|
||||
|
||||
def _load_approval_mode() -> str:
|
||||
from hermes_cli.config import DEFAULT_CONFIG, _deep_merge
|
||||
from tools.approval import _normalize_approval_mode
|
||||
|
||||
raw_cfg = _load_cfg()
|
||||
cfg = _deep_merge(DEFAULT_CONFIG, raw_cfg if isinstance(raw_cfg, dict) else {})
|
||||
approvals = cfg.get("approvals")
|
||||
raw = approvals.get("mode") if isinstance(approvals, dict) else None
|
||||
mode = _normalize_approval_mode(raw)
|
||||
return mode if mode in _APPROVAL_MODES else "manual"
|
||||
|
||||
|
||||
def _coerce_statusbar(raw) -> str:
|
||||
|
|
@ -3321,7 +3334,8 @@ def _current_profile_name() -> str:
|
|||
# checkout), surfacing a one-click "update to align" prompt instead of failing
|
||||
# cryptically downstream. Bump whenever the desktop's backend contract changes.
|
||||
# v2: adds the file.attach RPC (remote-gateway non-image file upload).
|
||||
DESKTOP_BACKEND_CONTRACT = 2
|
||||
# v3: adds approvals.mode config RPCs and session.info reconciliation.
|
||||
DESKTOP_BACKEND_CONTRACT = 3
|
||||
|
||||
|
||||
def _session_info(agent, session: dict | None = None) -> dict:
|
||||
|
|
@ -3355,17 +3369,15 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|||
# the desktop status bar (it would show YOLO "off" while approvals.mode=off
|
||||
# silently auto-approves every dangerous command).
|
||||
yolo = False
|
||||
approval_mode = "manual"
|
||||
try:
|
||||
from tools.approval import (
|
||||
_YOLO_MODE_FROZEN,
|
||||
_get_approval_mode,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled
|
||||
|
||||
session_yolo = (
|
||||
bool(is_session_yolo_enabled(session_key)) if session_key else False
|
||||
)
|
||||
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off"
|
||||
approval_mode = _load_approval_mode()
|
||||
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off"
|
||||
except Exception:
|
||||
yolo = False
|
||||
info: dict = {
|
||||
|
|
@ -3375,6 +3387,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|||
"service_tier": service_tier,
|
||||
"fast": service_tier == "priority",
|
||||
"yolo": yolo,
|
||||
"approval_mode": approval_mode,
|
||||
"tools": {},
|
||||
"skills": {},
|
||||
"cwd": cwd,
|
||||
|
|
@ -10399,6 +10412,22 @@ def _(rid, params: dict) -> dict:
|
|||
agent.verbose_logging = nv == "verbose"
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
if key in {"approval_mode", "approvals.mode"}:
|
||||
raw = str(value or "").strip().lower()
|
||||
if raw not in _APPROVAL_MODES:
|
||||
return _err(
|
||||
rid,
|
||||
4002,
|
||||
f"unknown approval mode: {value}; pick one of manual|smart|off",
|
||||
)
|
||||
|
||||
_write_config_key("approvals.mode", raw)
|
||||
for sid, sess in list(_sessions.items()):
|
||||
agent = sess.get("agent")
|
||||
if agent is not None:
|
||||
_emit("session.info", sid, _session_info(agent, sess))
|
||||
return _ok(rid, {"key": "approvals.mode", "value": raw})
|
||||
|
||||
if key == "yolo":
|
||||
# Approval bypass. Two scopes:
|
||||
# scope="session" (default) — same as the TUI's Shift+Tab. Toggles
|
||||
|
|
@ -11312,6 +11341,11 @@ def _(rid, params: dict) -> dict:
|
|||
)
|
||||
if key == "busy":
|
||||
return _ok(rid, {"value": _load_busy_input_mode()})
|
||||
if key in {"approval_mode", "approvals.mode"}:
|
||||
try:
|
||||
return _ok(rid, {"value": _load_approval_mode()})
|
||||
except Exception as e:
|
||||
return _err(rid, 5001, str(e))
|
||||
if key == "details_mode":
|
||||
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
|
||||
raw = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue