mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge remote-tracking branch 'origin/main' into bb/computer-use-perf
# Conflicts: # hermes_cli/config.py # tools/computer_use/cua_backend.py
This commit is contained in:
commit
cc1765dce2
32 changed files with 1365 additions and 100 deletions
|
|
@ -316,12 +316,21 @@ def evaluate_credits_notices(
|
|||
active.discard(CREDITS_USAGE_KEY)
|
||||
if target_band is not None:
|
||||
# Belt-and-suspenders: a producer could set subscription_limit_micros
|
||||
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
|
||||
# without subscription_limit_usd. Render "$?" rather than "$None".
|
||||
_cap_usd = state.subscription_limit_usd or "?"
|
||||
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
|
||||
# Report absolute dollars used, not a bare "N% used": the percentage is
|
||||
# only meaningful against a Nous subscription cap (no cap → never fires),
|
||||
# so dollars are clearer and don't imply a universal %. Used = cap −
|
||||
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
|
||||
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
|
||||
_lim = state.subscription_limit_micros or 0
|
||||
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
|
||||
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
|
||||
_glyph = "⚠" if _level == "warn" else "•"
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap",
|
||||
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
|
||||
level=_level,
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key=CREDITS_USAGE_KEY,
|
||||
|
|
|
|||
128
apps/desktop/src/app/contrib/dev/credits-notice-demo.ts
Normal file
128
apps/desktop/src/app/contrib/dev/credits-notice-demo.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Dev-only: drive the credit-notice UX without a backend that can hit real
|
||||
// usage bands. Each trigger emits ONE synthetic `notification.show` /
|
||||
// `notification.clear` gateway event through the real fan-out
|
||||
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
|
||||
// `use-message-stream/gateway-event.ts` — toast render, key-replacement
|
||||
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
|
||||
//
|
||||
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
|
||||
// of this ships in a production build.
|
||||
|
||||
import type { GatewayEvent } from '@hermes/shared'
|
||||
|
||||
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { CreditCard } from '@/lib/icons'
|
||||
import { emitLocalGatewayEvent } from '@/store/gateway'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
interface NoticeStep {
|
||||
key: string
|
||||
level: string
|
||||
kind: 'sticky' | 'ttl'
|
||||
text: string
|
||||
ttl_ms?: number
|
||||
}
|
||||
|
||||
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
|
||||
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
|
||||
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
|
||||
// previous notice when the key changes, so the demo shows one toast at a time
|
||||
// (real usage CAN stack these, but that's noise when you're eyeballing a single
|
||||
// transition). The same-key usage steps still demonstrate in-place escalation.
|
||||
const STEPS: readonly NoticeStep[] = [
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
|
||||
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
|
||||
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
|
||||
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
|
||||
]
|
||||
|
||||
let cursor = 0
|
||||
let lastShownKey: null | string = null
|
||||
|
||||
function clearNotice(key: string): void {
|
||||
emitLocalGatewayEvent({
|
||||
payload: { key },
|
||||
session_id: $activeSessionId.get() ?? '',
|
||||
type: 'notification.clear'
|
||||
} as GatewayEvent)
|
||||
}
|
||||
|
||||
function showNotice(step: NoticeStep): void {
|
||||
emitLocalGatewayEvent({
|
||||
payload: {
|
||||
id: `${step.key}:${Date.now()}`,
|
||||
key: step.key,
|
||||
kind: step.kind,
|
||||
level: step.level,
|
||||
text: step.text,
|
||||
ttl_ms: step.ttl_ms ?? null
|
||||
},
|
||||
session_id: $activeSessionId.get() ?? '',
|
||||
type: 'notification.show'
|
||||
} as GatewayEvent)
|
||||
}
|
||||
|
||||
/** Fire the next notice in the scripted sequence, wrapping at the end. */
|
||||
export function stepCreditsNoticeDemo(): void {
|
||||
const step = STEPS[cursor % STEPS.length]
|
||||
|
||||
// One toast at a time: retire the previous notice when we move to a new key.
|
||||
// (Same-key steps skip this, so the usage line still escalates in place.)
|
||||
if (lastShownKey && lastShownKey !== step.key) {
|
||||
clearNotice(lastShownKey)
|
||||
}
|
||||
|
||||
showNotice(step)
|
||||
lastShownKey = step.key
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
|
||||
// with a system Cmd chord and works the same on Windows/Linux). Matched on
|
||||
// `code` so it's keyboard-layout independent, and captured before the composer
|
||||
// so a focused input can't swallow it.
|
||||
function isTriggerChord(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette
|
||||
* entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
|
||||
*/
|
||||
export function installCreditsNoticeDemo(): () => void {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isTriggerChord(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
stepCreditsNoticeDemo()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
|
||||
|
||||
const disposePalette = registry.register({
|
||||
id: 'dev.creditsNotice',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'dev.creditsNotice',
|
||||
icon: CreditCard,
|
||||
keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
|
||||
label: 'Dev: cycle credit notices',
|
||||
run: stepCreditsNoticeDemo
|
||||
} satisfies PaletteContribution
|
||||
})
|
||||
|
||||
console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
disposePalette()
|
||||
delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
|
||||
}
|
||||
}
|
||||
|
|
@ -272,6 +272,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
|
||||
}, [navigate])
|
||||
|
||||
// Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette
|
||||
// / window.__creditsDemo). Dynamic import inside the DEV guard so the module
|
||||
// is dropped from production builds.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
return
|
||||
}
|
||||
|
||||
let dispose: (() => void) | undefined
|
||||
|
||||
void import('./dev/credits-notice-demo').then(m => {
|
||||
dispose = m.installCreditsNoticeDemo()
|
||||
})
|
||||
|
||||
return () => dispose?.()
|
||||
}, [])
|
||||
|
||||
// Post-turn rehydrate from stored history (same behavior as DesktopController,
|
||||
// including finished-todos restoration).
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
|
|
@ -865,6 +866,37 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
]
|
||||
}))
|
||||
}
|
||||
} else if (event.type === 'notification.show') {
|
||||
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
|
||||
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
|
||||
// status bar; the desktop renders them as toasts. The notice key doubles
|
||||
// as the toast id, so the escalating 50→75→90 credits line replaces in
|
||||
// place instead of stacking. Account-wide signal — shown regardless of
|
||||
// which session is focused.
|
||||
const notice = event.payload as AgentNoticePayload | undefined
|
||||
|
||||
showAgentNotice(notice)
|
||||
|
||||
// The urgent pair (access paused / restored) also breaks through as a
|
||||
// native OS notification when Hermes is backgrounded; dispatch is gated
|
||||
// by the user's notification prefs + backgrounded check.
|
||||
const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
|
||||
|
||||
if (native) {
|
||||
dispatchNativeNotification(native)
|
||||
}
|
||||
|
||||
// A credits crossing moves the account balance. Settings → Billing polls
|
||||
// `billing.state` every 30s; nudge it so the page reflects the crossing
|
||||
// immediately instead of up to 30s late.
|
||||
if (notice?.key?.startsWith('credits.')) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
}
|
||||
} else if (event.type === 'notification.clear') {
|
||||
// Key-matched dismissal (e.g. credits restored clears the depleted
|
||||
// notice). notify() keys the toast by the notice key, so this maps
|
||||
// straight to dismissNotification(key).
|
||||
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
|
|
|||
|
|
@ -11,15 +11,22 @@ export const EMPTY_BILLING_VALUE = '—'
|
|||
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
|
||||
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
|
||||
|
||||
// Billing polls on its own while the page is mounted (react-query only ticks an
|
||||
// active observer), so the view stays live without a manual refresh control —
|
||||
// matching every other data view in the app. It pauses when the window is
|
||||
// backgrounded (refetchIntervalInBackground defaults to false).
|
||||
// The billing endpoint is the authoritative source of truth for balance / cap /
|
||||
// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
|
||||
// out of sync (notably in team/org accounts where another member's spend moves
|
||||
// the shared balance without ever touching THIS client's headers). So the page
|
||||
// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
|
||||
// fresh fetch every time it opens or regains focus, and it keeps polling every
|
||||
// 30s while mounted (react-query only ticks an active observer; it pauses when
|
||||
// the window is backgrounded — refetchIntervalInBackground defaults to false).
|
||||
// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
|
||||
// pull the change in immediately rather than waiting for the next poll tick.
|
||||
const BILLING_QUERY_OPTIONS = {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
staleTime: 30_000
|
||||
staleTime: 0
|
||||
} as const
|
||||
|
||||
export interface BillingSummaryItemView {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
|
@ -162,6 +162,30 @@ function BottomRightStack({
|
|||
)
|
||||
}
|
||||
|
||||
// Emphasize only the leading money figure ("$16.00" — the amount used) with the
|
||||
// accent color (semibold), leaving the rest of the line in its default muted
|
||||
// tone. No accent, or no figure in the message → render the text untouched.
|
||||
function renderMessage(message: string, accent?: string): ReactNode {
|
||||
const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null
|
||||
|
||||
if (!match) {
|
||||
return message
|
||||
}
|
||||
|
||||
const start = match.index
|
||||
const end = start + match[0].length
|
||||
|
||||
return (
|
||||
<>
|
||||
{message.slice(0, start)}
|
||||
<span className="font-semibold" style={{ color: accent }}>
|
||||
{match[0]}
|
||||
</span>
|
||||
{message.slice(end)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const styles = tone[notification.kind]
|
||||
const Icon = styles.icon
|
||||
|
|
@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
|||
const { t } = useI18n()
|
||||
const copy = t.notifications
|
||||
|
||||
// Nudge the icon down to sit on the first text line, in `ch` so it tracks the
|
||||
// toast's font size instead of a fixed rem. `accentColor` (when set) tints the
|
||||
// icon + message as a severity ramp, overriding the kind's default color.
|
||||
const accent = notification.accentColor
|
||||
const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) }
|
||||
|
||||
return (
|
||||
<Alert
|
||||
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
|
||||
|
|
@ -177,14 +207,17 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
|||
variant={styles.variant}
|
||||
>
|
||||
{notification.icon ? (
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" />
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" style={iconStyle} />
|
||||
) : (
|
||||
<Icon className={styles.iconClass} />
|
||||
<Icon className={styles.iconClass} style={iconStyle} />
|
||||
)}
|
||||
<div className="col-start-2 min-w-0">
|
||||
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
|
||||
<AlertDescription className="col-start-auto">
|
||||
<p className="m-0">{notification.message}</p>
|
||||
<p className="m-0">{renderMessage(notification.message, accent)}</p>
|
||||
{notification.meta && (
|
||||
<p className="m-0 text-xs text-muted-foreground tabular-nums">{notification.meta}</p>
|
||||
)}
|
||||
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
|
||||
{notification.action && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -168,7 +168,8 @@ export const en: Translations = {
|
|||
turnDoneBody: 'The response is ready.',
|
||||
turnErrorTitle: 'Turn failed',
|
||||
backgroundDoneTitle: 'Background task finished',
|
||||
backgroundFailedTitle: 'Background task failed'
|
||||
backgroundFailedTitle: 'Background task failed',
|
||||
creditsTitle: 'Credits'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -378,6 +379,10 @@ export const en: Translations = {
|
|||
backgroundDone: {
|
||||
label: 'Background task finished',
|
||||
description: 'A backgrounded terminal command completed.'
|
||||
},
|
||||
credits: {
|
||||
label: 'Credit alerts',
|
||||
description: 'Credit access is paused or restored.'
|
||||
}
|
||||
},
|
||||
test: 'Send test notification',
|
||||
|
|
|
|||
|
|
@ -169,7 +169,8 @@ export const ja = defineLocale({
|
|||
turnDoneBody: '応答の準備ができました。',
|
||||
turnErrorTitle: 'ターンが失敗しました',
|
||||
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました',
|
||||
creditsTitle: 'クレジット'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -262,6 +263,10 @@ export const ja = defineLocale({
|
|||
backgroundDone: {
|
||||
label: 'バックグラウンドタスク完了',
|
||||
description: 'バックグラウンドのターミナルコマンドが完了しました。'
|
||||
},
|
||||
credits: {
|
||||
label: 'クレジット通知',
|
||||
description: 'クレジットの利用が停止または復旧しました。'
|
||||
}
|
||||
},
|
||||
test: 'テスト通知を送信',
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ export interface Translations {
|
|||
turnErrorTitle: string
|
||||
backgroundDoneTitle: string
|
||||
backgroundFailedTitle: string
|
||||
creditsTitle: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,7 +315,7 @@ export interface Translations {
|
|||
enableAllDesc: string
|
||||
focusedHint: string
|
||||
kinds: Record<
|
||||
'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError',
|
||||
'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError',
|
||||
{ label: string; description: string }
|
||||
>
|
||||
test: string
|
||||
|
|
|
|||
|
|
@ -164,7 +164,8 @@ export const zhHant = defineLocale({
|
|||
turnDoneBody: '回覆已就緒。',
|
||||
turnErrorTitle: '本輪失敗',
|
||||
backgroundDoneTitle: '背景工作已完成',
|
||||
backgroundFailedTitle: '背景工作失敗'
|
||||
backgroundFailedTitle: '背景工作失敗',
|
||||
creditsTitle: '額度'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -256,6 +257,10 @@ export const zhHant = defineLocale({
|
|||
backgroundDone: {
|
||||
label: '背景工作完成',
|
||||
description: '背景終端機指令已完成。'
|
||||
},
|
||||
credits: {
|
||||
label: '額度提醒',
|
||||
description: '額度存取被暫停或恢復。'
|
||||
}
|
||||
},
|
||||
test: '傳送測試通知',
|
||||
|
|
|
|||
|
|
@ -164,7 +164,8 @@ export const zh: Translations = {
|
|||
turnDoneBody: '回复已就绪。',
|
||||
turnErrorTitle: '本轮失败',
|
||||
backgroundDoneTitle: '后台任务已完成',
|
||||
backgroundFailedTitle: '后台任务失败'
|
||||
backgroundFailedTitle: '后台任务失败',
|
||||
creditsTitle: '额度'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -368,6 +369,10 @@ export const zh: Translations = {
|
|||
backgroundDone: {
|
||||
label: '后台任务完成',
|
||||
description: '后台终端命令已完成。'
|
||||
},
|
||||
credits: {
|
||||
label: '额度提醒',
|
||||
description: '额度访问被暂停或恢复。'
|
||||
}
|
||||
},
|
||||
test: '发送测试通知',
|
||||
|
|
|
|||
206
apps/desktop/src/store/agent-notices.test.ts
Normal file
206
apps/desktop/src/store/agent-notices.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { beforeEach, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
type AgentNoticePayload,
|
||||
clearAgentNotice,
|
||||
nativeNoticeInput,
|
||||
noticeAccent,
|
||||
noticeToToast,
|
||||
showAgentNotice,
|
||||
splitMeta,
|
||||
stripGlyph,
|
||||
usageFraction
|
||||
} from './agent-notices'
|
||||
import { $notifications, clearNotifications } from './notifications'
|
||||
|
||||
function usage(overrides: Partial<AgentNoticePayload> = {}): AgentNoticePayload {
|
||||
return {
|
||||
key: 'credits.usage',
|
||||
kind: 'sticky',
|
||||
level: 'info',
|
||||
text: "• You've used $110.00 of your $220.00 cap",
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearNotifications()
|
||||
})
|
||||
|
||||
// ── noticeToToast: the whole mapping contract ────────────────────────────────
|
||||
|
||||
test('drops a notice with no text', () => {
|
||||
expect(noticeToToast(undefined)).toBeNull()
|
||||
expect(noticeToToast({ text: '' })).toBeNull()
|
||||
expect(noticeToToast({ text: ' ' })).toBeNull()
|
||||
})
|
||||
|
||||
test('level maps to toast kind (warn → warning)', () => {
|
||||
expect(noticeToToast(usage({ level: 'info' }))?.kind).toBe('info')
|
||||
expect(noticeToToast(usage({ level: 'warn' }))?.kind).toBe('warning')
|
||||
expect(noticeToToast(usage({ level: 'error' }))?.kind).toBe('error')
|
||||
expect(noticeToToast(usage({ level: 'success' }))?.kind).toBe('success')
|
||||
})
|
||||
|
||||
test('unknown / missing level falls back to info', () => {
|
||||
expect(noticeToToast({ text: 'x', level: 'bogus' })?.kind).toBe('info')
|
||||
expect(noticeToToast({ text: 'x' })?.kind).toBe('info')
|
||||
})
|
||||
|
||||
test('sticky notices never auto-dismiss', () => {
|
||||
expect(noticeToToast(usage({ kind: 'sticky' }))?.durationMs).toBe(0)
|
||||
})
|
||||
|
||||
test('ttl notice carries its ttl_ms as the duration', () => {
|
||||
const toast = noticeToToast({ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 })
|
||||
expect(toast?.durationMs).toBe(8000)
|
||||
})
|
||||
|
||||
test('ttl notice without a usable ttl_ms defers to notify()’s default', () => {
|
||||
expect(noticeToToast({ text: 'x', kind: 'ttl' })?.durationMs).toBeUndefined()
|
||||
expect(noticeToToast({ text: 'x', kind: 'ttl', ttl_ms: 0 })?.durationMs).toBeUndefined()
|
||||
})
|
||||
|
||||
test('the notice key is the toast id, falling back to id', () => {
|
||||
expect(noticeToToast(usage({ key: 'credits.usage' }))?.id).toBe('credits.usage')
|
||||
expect(noticeToToast({ text: 'x', id: 'n1', key: undefined })?.id).toBe('n1')
|
||||
})
|
||||
|
||||
test('the leading severity glyph is stripped from the toast message', () => {
|
||||
// The toast renders a kind icon, so the message must not double up on a glyph.
|
||||
expect(noticeToToast(usage())?.message).toBe("You've used $110.00 of your $220.00 cap")
|
||||
expect(noticeToToast(usage({ level: 'error', text: '✕ Credit access paused' }))?.message).toBe('Credit access paused')
|
||||
expect(noticeToToast(usage({ level: 'success', text: '✓ Credit access restored' }))?.message).toBe(
|
||||
'Credit access restored'
|
||||
)
|
||||
})
|
||||
|
||||
test('the trailing "· detail" is split off as a secondary meta line, not inlined', () => {
|
||||
// grant_spent still carries a `· detail` tail.
|
||||
const grant = noticeToToast({ key: 'credits.grant_spent', level: 'info', text: '• Grant spent · $12.00 top-up left' })
|
||||
expect(grant?.message).toBe('Grant spent')
|
||||
expect(grant?.meta).toBe('$12.00 top-up left')
|
||||
|
||||
// The usage line has no middot → whole line is the message, no meta.
|
||||
const plain = noticeToToast(usage())
|
||||
expect(plain?.message).toBe("You've used $110.00 of your $220.00 cap")
|
||||
expect(plain?.meta).toBeUndefined()
|
||||
})
|
||||
|
||||
test('splitMeta splits on the first space-middot-space only', () => {
|
||||
expect(splitMeta('Grant spent · $12.00 top-up left')).toEqual(['Grant spent', '$12.00 top-up left'])
|
||||
expect(splitMeta('Credit access restored')).toEqual(['Credit access restored', undefined])
|
||||
// Interior middots after the first split stay in the meta.
|
||||
expect(splitMeta('a · b · c')).toEqual(['a', 'b · c'])
|
||||
})
|
||||
|
||||
test('stripGlyph removes only a single leading severity glyph', () => {
|
||||
expect(stripGlyph('• Credits 50% used')).toBe('Credits 50% used')
|
||||
expect(stripGlyph('⚠ warn')).toBe('warn')
|
||||
expect(stripGlyph('✕ paused')).toBe('paused')
|
||||
expect(stripGlyph('✓ ok')).toBe('ok')
|
||||
// No leading glyph → unchanged; interior glyphs are preserved.
|
||||
expect(stripGlyph('Credits 50% used')).toBe('Credits 50% used')
|
||||
expect(stripGlyph('spent · $12.00 • top-up left')).toBe('spent · $12.00 • top-up left')
|
||||
})
|
||||
|
||||
// ── noticeAccent: severity color ramp keyed off $used / $cap ─────────────────
|
||||
|
||||
test('usageFraction derives $used / $cap from the notice text', () => {
|
||||
expect(usageFraction("You've used $15.00 of your $20.00 cap")).toBeCloseTo(0.75)
|
||||
expect(usageFraction("You've used $198.00 of your $220.00 cap")).toBeCloseTo(0.9)
|
||||
// Fewer than two amounts, or a zero cap → no fraction.
|
||||
expect(usageFraction('Grant spent')).toBeNull()
|
||||
expect(usageFraction("You've used $5.00 of your $0.00 cap")).toBeNull()
|
||||
expect(usageFraction(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test('usage accent stays muted below 75%, then ramps orange → red', () => {
|
||||
expect(noticeAccent(usage({ text: "• You've used $10.00 of your $20.00 cap" }))).toBeUndefined() // 50%
|
||||
expect(noticeAccent(usage({ text: "• You've used $14.80 of your $20.00 cap" }))).toBeUndefined() // 74%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))).toBe('var(--ui-orange)') // 75%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $17.80 of your $20.00 cap" }))).toBe('var(--ui-orange)') // 89%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 90%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $20.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 100%
|
||||
})
|
||||
|
||||
test('terminal credit states carry their own accent; others stay default', () => {
|
||||
expect(noticeAccent({ key: 'credits.depleted', text: '✕ paused' })).toBe('var(--ui-red)')
|
||||
expect(noticeAccent({ key: 'credits.restored', text: '✓ restored' })).toBe('var(--ui-green)')
|
||||
expect(noticeAccent({ key: 'credits.grant_spent', text: '• Grant spent' })).toBeUndefined()
|
||||
expect(noticeAccent(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('noticeToToast attaches the band accent to the toast', () => {
|
||||
expect(noticeToToast(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))?.accentColor).toBe(
|
||||
'var(--ui-orange)'
|
||||
)
|
||||
expect(noticeToToast(usage({ text: "• You've used $10.00 of your $20.00 cap" }))?.accentColor).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── show / clear: rendered through the notifications store ────────────────────
|
||||
|
||||
test('showAgentNotice renders a toast; empty text is a no-op', () => {
|
||||
showAgentNotice(usage())
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
expect($notifications.get()[0]?.id).toBe('credits.usage')
|
||||
|
||||
showAgentNotice({ text: '' })
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('re-emitting the same key replaces the toast instead of stacking (50→75→90)', () => {
|
||||
showAgentNotice(usage({ level: 'info', text: "• You've used $10.00 of your $20.00 cap" }))
|
||||
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))
|
||||
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))
|
||||
|
||||
const toasts = $notifications.get().filter(item => item.id === 'credits.usage')
|
||||
expect(toasts).toHaveLength(1)
|
||||
expect(toasts[0]?.message).toBe("You've used $18.00 of your $20.00 cap")
|
||||
expect(toasts[0]?.kind).toBe('warning')
|
||||
})
|
||||
|
||||
test('clearAgentNotice dismisses only the matching key', () => {
|
||||
showAgentNotice(usage())
|
||||
showAgentNotice({ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ paused' })
|
||||
expect($notifications.get()).toHaveLength(2)
|
||||
|
||||
clearAgentNotice('credits.usage')
|
||||
const ids = $notifications.get().map(item => item.id)
|
||||
expect(ids).toContain('credits.depleted')
|
||||
expect(ids).not.toContain('credits.usage')
|
||||
|
||||
clearAgentNotice(undefined)
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
})
|
||||
|
||||
// ── nativeNoticeInput: only the urgent credit pair breaks through the OS ──────
|
||||
|
||||
test('only credits.depleted and credits.restored map to a native notification', () => {
|
||||
expect(nativeNoticeInput(usage({ key: 'credits.usage' }), 'Credits')).toBeNull()
|
||||
expect(nativeNoticeInput(usage({ key: 'credits.grant_spent' }), 'Credits')).toBeNull()
|
||||
expect(nativeNoticeInput({ text: 'x', key: undefined }, 'Credits')).toBeNull()
|
||||
expect(nativeNoticeInput({ text: '', key: 'credits.depleted' }, 'Credits')).toBeNull()
|
||||
})
|
||||
|
||||
test('the urgent pair maps to a global native input carrying the text as its body', () => {
|
||||
const depleted = nativeNoticeInput(
|
||||
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
|
||||
'Credits'
|
||||
)
|
||||
|
||||
expect(depleted).toEqual({
|
||||
body: '✕ Credit access paused · run /topup to top up',
|
||||
global: true,
|
||||
kind: 'credits',
|
||||
title: 'Credits'
|
||||
})
|
||||
|
||||
const restored = nativeNoticeInput(
|
||||
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 },
|
||||
'Credits'
|
||||
)
|
||||
|
||||
expect(restored?.kind).toBe('credits')
|
||||
expect(restored?.body).toBe('✓ Credit access restored')
|
||||
})
|
||||
206
apps/desktop/src/store/agent-notices.ts
Normal file
206
apps/desktop/src/store/agent-notices.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import type { NativeNotificationInput } from '@/store/native-notifications'
|
||||
import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* Wire shape of a `notification.show` payload — the driver-agnostic
|
||||
* `AgentNotice` spine (`agent/credits_tracker.py`) as forwarded by
|
||||
* `tui_gateway/server.py`. Snake_case to match the wire.
|
||||
*
|
||||
* The `text` carries its own leading severity glyph (• ⚠ ✕ ✓) from the Python
|
||||
* policy — that's how the CLI/TUI render it (glyph in a status line, no separate
|
||||
* icon). The desktop toast is different: every toast renders a kind icon, so we
|
||||
* strip the leading glyph and let that icon carry severity (see `stripGlyph`),
|
||||
* otherwise the toast shows two markers. The native OS notification keeps the
|
||||
* glyph (it has no icon of ours).
|
||||
*
|
||||
* - `level` is severity: info | warn | error | success.
|
||||
* - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl`
|
||||
* (self-expires after `ttl_ms`).
|
||||
*/
|
||||
export interface AgentNoticePayload {
|
||||
text?: string
|
||||
level?: string
|
||||
kind?: string
|
||||
ttl_ms?: null | number
|
||||
key?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
|
||||
error: 'error',
|
||||
info: 'info',
|
||||
success: 'success',
|
||||
warn: 'warning'
|
||||
}
|
||||
|
||||
// The severity glyphs the Python notice policy prefixes (`•` `⚠` `✕`/`✗` `✓`),
|
||||
// optionally with a variation selector, plus trailing space. Stripped for the
|
||||
// desktop toast because the toast already renders a kind icon.
|
||||
const LEADING_GLYPH = /^[•⚠✕✗✓]\uFE0F?\s*/u
|
||||
|
||||
/** Drop a single leading severity glyph so the toast doesn't double up on it. */
|
||||
export function stripGlyph(text: string): string {
|
||||
return text.replace(LEADING_GLYPH, '')
|
||||
}
|
||||
|
||||
/** A `$12.34` money token, as the Nous notice policy formats amounts. */
|
||||
const MONEY = /\$(\d+(?:\.\d{2})?)/g
|
||||
|
||||
/**
|
||||
* Used fraction of the cap derived from a "You've used $X of your $Y cap" notice
|
||||
* — the first two money tokens are (used, cap). Returns a value in [0, 1], or
|
||||
* `null` when the line has no usable pair.
|
||||
*/
|
||||
export function usageFraction(text: string | undefined): null | number {
|
||||
const amounts = [...(text ?? '').matchAll(MONEY)].map(m => Number(m[1]))
|
||||
|
||||
if (amounts.length < 2 || !(amounts[1] > 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return amounts[0] / amounts[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Accent color for a credit notice, as a range/severity ramp. The usage gauge
|
||||
* keys off how much of the cap is spent ($used / $cap): it stays on the toast's
|
||||
* default muted color under 75%, then escalates to orange, then red, as the cap
|
||||
* nears. `credits.depleted` is red (paused) and `credits.restored` green.
|
||||
*
|
||||
* Returns a CSS color token or `undefined` (= keep the default muted color).
|
||||
* These reuse the app's existing usage palette (`--ui-*`; see the
|
||||
* `--context-usage-*` block in styles.css) — no new colors are introduced.
|
||||
*/
|
||||
export function noticeAccent(payload: AgentNoticePayload | undefined): string | undefined {
|
||||
if (payload?.key === 'credits.depleted') {
|
||||
return 'var(--ui-red)'
|
||||
}
|
||||
|
||||
if (payload?.key === 'credits.restored') {
|
||||
return 'var(--ui-green)'
|
||||
}
|
||||
|
||||
const frac = usageFraction(payload?.text)
|
||||
|
||||
if (frac === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (frac >= 0.9) {
|
||||
return 'var(--ui-red)'
|
||||
}
|
||||
|
||||
if (frac >= 0.75) {
|
||||
return 'var(--ui-orange)'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an agent notice to a toast input, or `null` when it carries no text.
|
||||
*
|
||||
* Pure and side-effect free so it can be unit-tested directly. The mapping is
|
||||
* the whole contract:
|
||||
* - `level` → toast kind (info/warn/error/success, warn→warning).
|
||||
* - `sticky` → `durationMs: 0` (persists); `ttl` → `durationMs: ttl_ms`.
|
||||
* - the notice `key` doubles as the toast `id`, so re-emitting the same key
|
||||
* REPLACES the prior toast — the credits 50→75→90 line escalates in place
|
||||
* instead of stacking, and a key-matched `notification.clear` can dismiss it.
|
||||
*/
|
||||
export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null {
|
||||
const text = payload?.text?.trim()
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isTtl = payload?.kind === 'ttl'
|
||||
const ttl = typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
|
||||
|
||||
// The Python notice text packs a trailing detail after a middot
|
||||
// (`… used · $220.00 cap`, `Grant spent · $12.00 top-up left`). On one CLI/TUI
|
||||
// status line that reads fine, but the toast follows the title-plus-description
|
||||
// convention (Sonner/shadcn): the primary status is the message and the detail
|
||||
// drops to a muted second line, instead of inlining a `·` separator.
|
||||
const [primary, meta] = splitMeta(stripGlyph(text))
|
||||
|
||||
return {
|
||||
// Icon + text tint by usage band (muted → orange → red); undefined keeps
|
||||
// the default muted color.
|
||||
accentColor: noticeAccent(payload),
|
||||
// sticky → 0 (never auto-dismiss); ttl with a ttl_ms → that value; a ttl
|
||||
// without a usable ttl_ms falls back to notify()'s per-kind default.
|
||||
durationMs: isTtl ? ttl : 0,
|
||||
id: payload?.key || payload?.id,
|
||||
kind: LEVEL_TO_TOAST_KIND[payload?.level ?? 'info'] ?? 'info',
|
||||
message: primary,
|
||||
meta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a notice line into its primary status and a trailing detail on the first
|
||||
* ` · ` (space-middot-space). No middot → the whole line is the primary and
|
||||
* there's no meta.
|
||||
*/
|
||||
export function splitMeta(text: string): [primary: string, meta: string | undefined] {
|
||||
const at = text.indexOf(' · ')
|
||||
|
||||
if (at === -1) {
|
||||
return [text, undefined]
|
||||
}
|
||||
|
||||
return [text.slice(0, at), text.slice(at + 3) || undefined]
|
||||
}
|
||||
|
||||
/** Render a `notification.show` notice as a toast (no-op when it has no text). */
|
||||
export function showAgentNotice(payload: AgentNoticePayload | undefined): void {
|
||||
const toast = noticeToToast(payload)
|
||||
|
||||
if (toast) {
|
||||
notify(toast)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the toast a `notification.clear` targets. The clear only ever names a
|
||||
* `key`, which we used as the toast id, so this is a key-matched dismissal.
|
||||
*/
|
||||
export function clearAgentNotice(key: string | undefined): void {
|
||||
if (key) {
|
||||
dismissNotification(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Only these two credit notices are urgent enough to break through as a native
|
||||
// OS notification (when Hermes is backgrounded). The escalating usage line
|
||||
// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they
|
||||
// aren't worth interrupting the user's OS for.
|
||||
const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored'])
|
||||
|
||||
/**
|
||||
* Map a notice to a native OS notification input, or `null` when it isn't one of
|
||||
* the urgent credit notices. Pure — the caller passes the localized `title` and
|
||||
* decides whether to dispatch. `global: true` because credit state is
|
||||
* account-wide, not tied to a chat session, so it should fire whenever the user
|
||||
* is away regardless of which session (if any) is focused. The notice `text`
|
||||
* already carries its glyph and is passed through as the raw body.
|
||||
*/
|
||||
export function nativeNoticeInput(
|
||||
payload: AgentNoticePayload | undefined,
|
||||
title: string
|
||||
): NativeNotificationInput | null {
|
||||
const text = payload?.text?.trim()
|
||||
|
||||
if (!text || !payload?.key || !NATIVE_NOTICE_KEYS.has(payload.key)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
body: text,
|
||||
global: true,
|
||||
kind: 'credits',
|
||||
title
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,17 @@ export function configureGatewayRegistry(cfg: RegistryConfig): void {
|
|||
config = cfg
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a synthetic event through the exact same fan-out a real socket frame
|
||||
* takes (`config.onEvent` → the desktop's `handleGatewayEvent`). Used by
|
||||
* dev-only tooling to exercise the real event branches (e.g. the credit-notice
|
||||
* demo) without a backend that can produce the event on demand. No-op until a
|
||||
* registry is configured.
|
||||
*/
|
||||
export function emitLocalGatewayEvent(event: GatewayEvent): void {
|
||||
config?.onEvent(event)
|
||||
}
|
||||
|
||||
// ── Primary (window) backend ───────────────────────────────────────────────
|
||||
let primaryGateway: HermesGateway | null = null
|
||||
let primaryProfile = 'default'
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@ import { $activeSessionId } from './session'
|
|||
|
||||
// Native OS notifications (Electron `Notification`), separate from the in-app
|
||||
// toast feed in `notifications.ts`. Each kind toggles independently.
|
||||
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
|
||||
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError'
|
||||
|
||||
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
||||
'approval',
|
||||
'input',
|
||||
'turnDone',
|
||||
'turnError',
|
||||
'backgroundDone'
|
||||
'backgroundDone',
|
||||
'credits'
|
||||
]
|
||||
|
||||
// Blocking prompts — surface even while focused if they're for another session.
|
||||
|
|
@ -30,7 +31,7 @@ const STORAGE_KEY = 'hermes:native-notifications'
|
|||
|
||||
const DEFAULT_PREFS: NativeNotificationPrefs = {
|
||||
enabled: true,
|
||||
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
|
||||
kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true }
|
||||
}
|
||||
|
||||
function readPrefs(): NativeNotificationPrefs {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ export interface AppNotification {
|
|||
kind: NotificationKind
|
||||
/** When set, renders this codicon instead of the default kind icon. */
|
||||
icon?: string
|
||||
/** When set, tints the icon and message with this CSS color (severity ramp). */
|
||||
accentColor?: string
|
||||
/** Secondary detail line rendered below the message, muted (e.g. "$220.00 cap"). */
|
||||
meta?: string
|
||||
title?: string
|
||||
message: string
|
||||
detail?: string
|
||||
|
|
@ -25,10 +29,12 @@ export interface AppNotification {
|
|||
placement?: NotificationPlacement
|
||||
}
|
||||
|
||||
interface NotificationInput {
|
||||
export interface NotificationInput {
|
||||
id?: string
|
||||
kind?: NotificationKind
|
||||
icon?: string
|
||||
accentColor?: string
|
||||
meta?: string
|
||||
title?: string
|
||||
message: string
|
||||
detail?: string
|
||||
|
|
@ -130,6 +136,8 @@ export function notify(input: NotificationInput): string {
|
|||
id,
|
||||
kind,
|
||||
icon: input.icon,
|
||||
accentColor: input.accentColor,
|
||||
meta: input.meta,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
detail: input.detail,
|
||||
|
|
|
|||
2
contributors/emails/adrian.soto6@gmail.com
Normal file
2
contributors/emails/adrian.soto6@gmail.com
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
adriansotomora
|
||||
# PR #58831 salvage
|
||||
2
contributors/emails/yuntianqing@yahoo.com
Normal file
2
contributors/emails/yuntianqing@yahoo.com
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
zengzheqing
|
||||
# PR #55631 salvage
|
||||
|
|
@ -3480,6 +3480,15 @@ DEFAULT_CONFIG = {
|
|||
# Mode for capture_after follow-ups: som (screenshot + overlays —
|
||||
# default), ax (elements only, no PNG — faster), vision (pixels only).
|
||||
"capture_after_mode": "som",
|
||||
# Disable the cursor overlay rendered by cua-driver. The overlay
|
||||
# shows where agent actions land but can peg a core when idle
|
||||
# (macOS vImage redraw loop #47032; Linux/WSL2 idle spin #28152).
|
||||
# cua-driver ≥ 0.6.x supports --no-overlay; Hermes also calls
|
||||
# set_agent_cursor_enabled(false) after start_session when this is on.
|
||||
# None = auto-detect (off on macOS + headless/WSL2 Linux; on elsewhere)
|
||||
# True = always disable the overlay
|
||||
# False = always enable the overlay
|
||||
"no_overlay": None,
|
||||
},
|
||||
|
||||
# Hermes Desktop (Electron app) launch options. These only affect
|
||||
|
|
|
|||
|
|
@ -14456,14 +14456,14 @@ def main():
|
|||
install_cua_driver(upgrade=bool(getattr(args, "upgrade", False)))
|
||||
return
|
||||
if action == "status":
|
||||
import shutil
|
||||
import subprocess
|
||||
from hermes_cli.tools_config import _cua_driver_cmd
|
||||
# Honor HERMES_CUA_DRIVER_CMD for local-build testing — same
|
||||
# resolver `install_cua_driver` and the runtime backend use,
|
||||
# so `status` reports what `computer_use` will actually invoke.
|
||||
driver_cmd = _cua_driver_cmd()
|
||||
path = shutil.which(driver_cmd)
|
||||
from tools.computer_use.cua_backend import (
|
||||
cua_driver_update_check,
|
||||
resolve_cua_driver_cmd,
|
||||
)
|
||||
# Must match the runtime resolver: Desktop/TUI processes can omit
|
||||
# ~/.local/bin even though the official installer put the driver there.
|
||||
path = resolve_cua_driver_cmd()
|
||||
if path:
|
||||
version = ""
|
||||
try:
|
||||
|
|
@ -14480,7 +14480,6 @@ def main():
|
|||
else:
|
||||
print(f"cua-driver: installed at {path}")
|
||||
try:
|
||||
from tools.computer_use.cua_backend import cua_driver_update_check
|
||||
st = cua_driver_update_check()
|
||||
if st and st.get("update_available"):
|
||||
latest = st.get("latest_version") or "?"
|
||||
|
|
|
|||
|
|
@ -649,10 +649,17 @@ TOOLSET_ENV_REQUIREMENTS = {
|
|||
|
||||
|
||||
def _cua_driver_cmd() -> str:
|
||||
"""Return the cua-driver executable name/path, honoring non-empty overrides."""
|
||||
"""Return the configured cua-driver override, or the bare default name."""
|
||||
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
|
||||
|
||||
|
||||
def _resolved_cua_driver_cmd() -> Optional[str]:
|
||||
"""Resolve cua-driver exactly as the runtime and Desktop status do."""
|
||||
from tools.computer_use.cua_backend import resolve_cua_driver_cmd
|
||||
|
||||
return resolve_cua_driver_cmd()
|
||||
|
||||
|
||||
def _cua_driver_env() -> dict:
|
||||
"""cua-driver child env with the Hermes telemetry policy applied.
|
||||
|
||||
|
|
@ -824,7 +831,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
|
|||
fetch_tool = "powershell" if is_windows else "curl"
|
||||
|
||||
driver_cmd = _cua_driver_cmd()
|
||||
binary = shutil.which(driver_cmd)
|
||||
binary = _resolved_cua_driver_cmd()
|
||||
|
||||
# Not installed → fresh install path (only when caller asked for it).
|
||||
if not binary and not upgrade:
|
||||
|
|
@ -849,7 +856,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
|
|||
if binary and not upgrade:
|
||||
try:
|
||||
version = subprocess.run(
|
||||
[driver_cmd, "--version"],
|
||||
[binary, "--version"],
|
||||
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
).stdout.strip()
|
||||
|
|
@ -907,7 +914,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
|
|||
# Show before/after version when we have a baseline. Best-effort.
|
||||
try:
|
||||
before = subprocess.run(
|
||||
[driver_cmd, "--version"],
|
||||
[binary, "--version"],
|
||||
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
).stdout.strip()
|
||||
|
|
@ -920,7 +927,7 @@ def install_cua_driver(upgrade: bool = False) -> bool:
|
|||
if ok and before:
|
||||
try:
|
||||
after = subprocess.run(
|
||||
[driver_cmd, "--version"],
|
||||
[binary, "--version"],
|
||||
capture_output=True, text=True, timeout=5, env=_cua_driver_env(),
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
).stdout.strip()
|
||||
|
|
@ -2672,7 +2679,7 @@ _POST_SETUP_INSTALLED: dict = {
|
|||
# entry when (a) the post_setup is the ONLY install side-effect for
|
||||
# a no-key provider, and (b) an installed-state check is cheap and
|
||||
# doesn't trigger a heavy import.
|
||||
"cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())),
|
||||
"cua_driver": lambda: _resolved_cua_driver_cmd() is not None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2730,7 +2737,7 @@ _POST_SETUP_READY: dict = {
|
|||
"agent_browser": lambda: _agent_browser_installed(),
|
||||
"browserbase": lambda: _cloud_agent_browser_installed(),
|
||||
"camofox": lambda: _camofox_installed(),
|
||||
"cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())),
|
||||
"cua_driver": lambda: _resolved_cua_driver_cmd() is not None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -569,7 +569,8 @@ class TestTopUpSuppression:
|
|||
assert latch["usage_band"] is None
|
||||
to_show, _ = evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
n = next(n for n in to_show if n.key == "credits.usage")
|
||||
assert "90%" in n.text
|
||||
# uf 0.95 of a $20 cap → used = $19.00 (cap − remaining, clamped).
|
||||
assert "$19.00" in n.text
|
||||
assert latch["usage_band"] == 90
|
||||
|
||||
def test_grant_spent_still_fires_with_topup(self):
|
||||
|
|
@ -645,7 +646,8 @@ class TestUsageBands:
|
|||
evaluate_credits_notices(state_with_fraction(0.10), latch) # prime
|
||||
to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch)
|
||||
n = next(n for n in to_show if n.key == "credits.usage")
|
||||
assert "50%" in n.text and n.level == "info"
|
||||
# uf 0.55 of a $20 cap → used = $11.00; band 50 fires at info level.
|
||||
assert "$11.00" in n.text and n.level == "info"
|
||||
assert latch["usage_band"] == 50
|
||||
|
||||
def test_75_band_fires_warn(self):
|
||||
|
|
@ -653,7 +655,8 @@ class TestUsageBands:
|
|||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
to_show, _ = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
||||
n = next(n for n in to_show if n.key == "credits.usage")
|
||||
assert "75%" in n.text and n.level == "warn"
|
||||
# uf 0.80 of a $20 cap → used = $16.00; band 75 fires at warn level.
|
||||
assert "$16.00" in n.text and n.level == "warn"
|
||||
assert latch["usage_band"] == 75
|
||||
|
||||
def test_climb_replaces_band(self):
|
||||
|
|
@ -663,15 +666,15 @@ class TestUsageBands:
|
|||
# 55% → 50 band
|
||||
evaluate_credits_notices(state_with_fraction(0.55), latch)
|
||||
assert latch["usage_band"] == 50
|
||||
# 80% → climbs to 75, clearing the 50 line
|
||||
# 80% → climbs to 75, clearing the 50 line (used = $16.00 of $20)
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "75%" in self._band_text(to_show)
|
||||
assert "$16.00" in self._band_text(to_show)
|
||||
assert latch["usage_band"] == 75
|
||||
# 95% → climbs to 90
|
||||
# 95% → climbs to 90 (used = $19.00 of $20)
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "90%" in self._band_text(to_show)
|
||||
assert "$19.00" in self._band_text(to_show)
|
||||
assert latch["usage_band"] == 90
|
||||
|
||||
def test_step_down_on_recovery(self):
|
||||
|
|
@ -680,13 +683,13 @@ class TestUsageBands:
|
|||
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
||||
assert latch["usage_band"] == 90
|
||||
# drop to 80% → steps down to 75
|
||||
# drop to 80% → steps down to 75 (used = $16.00 of $20)
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
assert "75%" in self._band_text(to_show)
|
||||
# drop to 55% → steps down to 50
|
||||
assert "$16.00" in self._band_text(to_show)
|
||||
# drop to 55% → steps down to 50 (used = $11.00 of $20)
|
||||
to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch)
|
||||
assert "50%" in self._band_text(to_show)
|
||||
assert "$11.00" in self._band_text(to_show)
|
||||
# drop below 50% → clears entirely
|
||||
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch)
|
||||
assert "credits.usage" in to_clear
|
||||
|
|
|
|||
|
|
@ -31,8 +31,12 @@ def _fake_completed_process(stdout: str) -> MagicMock:
|
|||
|
||||
|
||||
def test_cli_fallback_strips_provider_secret_from_subprocess_env(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-super-secret-should-not-leak")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "«redacted:sk-…»")
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.setattr(
|
||||
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
lambda: "/resolved/cua-driver",
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
|
|
@ -56,6 +60,10 @@ def test_cli_fallback_applies_telemetry_policy(monkeypatch):
|
|||
"""The env should also go through cua_driver_child_env(), like every
|
||||
other cua-driver spawn site, not just _sanitize_subprocess_env alone."""
|
||||
monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
lambda: "/resolved/cua-driver",
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
|
|
|
|||
247
tests/computer_use/test_cua_no_overlay.py
Normal file
247
tests/computer_use/test_cua_no_overlay.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""Tests for the cua-driver --no-overlay policy.
|
||||
|
||||
cua-driver's cursor overlay rendering loop can consume CPU indefinitely when
|
||||
idle (#28152, #47032). Hermes passes ``--no-overlay`` to suppress it when the
|
||||
``computer_use.no_overlay`` config is enabled (or auto-detected on macOS and
|
||||
headless Linux / WSL2).
|
||||
|
||||
These assert the behavior contract (auto-detect, explicit override, version
|
||||
probe), not specific config snapshots.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
|
||||
class TestNoOverlayFlag:
|
||||
def test_default_linux_headless_disables(self):
|
||||
"""Auto-detect: Linux without DISPLAY => overlay disabled."""
|
||||
with patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(sys, "platform", "linux"), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("DISPLAY", None)
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
def test_default_linux_desktop_enables(self):
|
||||
"""Auto-detect: Linux with DISPLAY => overlay enabled."""
|
||||
with patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(sys, "platform", "linux"), \
|
||||
patch.dict(os.environ, {"DISPLAY": ":0"}):
|
||||
assert cua_backend._cua_no_overlay() is False
|
||||
|
||||
def test_default_linux_wsl2_disables(self):
|
||||
"""Auto-detect: WSL2 (microsoft in /proc/version) => overlay disabled."""
|
||||
fake_version = "Linux version 5.15.0 (Microsoft@Microsoft.com)"
|
||||
with patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(sys, "platform", "linux"), \
|
||||
patch.dict(os.environ, {"DISPLAY": ":0"}), \
|
||||
patch("builtins.open", mock_open(read_data=fake_version)):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
def test_default_macos_disables(self):
|
||||
"""Auto-detect: macOS => overlay disabled (idle CPU / #47032)."""
|
||||
with patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(sys, "platform", "darwin"):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
def test_default_windows_enables(self):
|
||||
"""Auto-detect: Windows => overlay enabled."""
|
||||
with patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(sys, "platform", "win32"):
|
||||
assert cua_backend._cua_no_overlay() is False
|
||||
|
||||
def test_explicit_true_overrides(self):
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"computer_use": {"no_overlay": True}}):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
def test_explicit_false_overrides(self):
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"computer_use": {"no_overlay": False}}), \
|
||||
patch.object(sys, "platform", "linux"), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("DISPLAY", None)
|
||||
# Explicit False overrides auto-detect on headless Linux.
|
||||
assert cua_backend._cua_no_overlay() is False
|
||||
|
||||
def test_config_load_failure_falls_through_to_auto_detect(self):
|
||||
"""Unreadable config => auto-detect (macOS defaults to disabled)."""
|
||||
with patch("hermes_cli.config.load_config",
|
||||
side_effect=RuntimeError("boom")), \
|
||||
patch.object(sys, "platform", "darwin"):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
def test_macos_explicit_false_keeps_overlay(self):
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"computer_use": {"no_overlay": False}}), \
|
||||
patch.object(sys, "platform", "darwin"):
|
||||
assert cua_backend._cua_no_overlay() is False
|
||||
|
||||
def test_missing_section_falls_through_to_auto_detect(self):
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"other": {}}), \
|
||||
patch.object(sys, "platform", "linux"), \
|
||||
patch.dict(os.environ, {"DISPLAY": ":0"}):
|
||||
assert cua_backend._cua_no_overlay() is False
|
||||
|
||||
|
||||
class TestDriverSupportsNoOverlay:
|
||||
def test_returns_true_when_help_shows_flag(self):
|
||||
fake_help = "Usage: cua-driver [OPTIONS] COMMAND\n --no-overlay Disable cursor overlay\n"
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value.stdout = fake_help
|
||||
mock_run.return_value.stderr = ""
|
||||
assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is True
|
||||
|
||||
def test_returns_false_when_help_lacks_flag(self):
|
||||
fake_help = "Usage: cua-driver [OPTIONS] COMMAND\n"
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value.stdout = fake_help
|
||||
mock_run.return_value.stderr = ""
|
||||
cua_backend._cua_driver_supports_no_overlay.cache_clear()
|
||||
assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is False
|
||||
|
||||
def test_returns_false_on_subprocess_error(self):
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("no such file")):
|
||||
cua_backend._cua_driver_supports_no_overlay.cache_clear()
|
||||
assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is False
|
||||
|
||||
def test_help_probe_passes_sanitized_env(self):
|
||||
"""The ``--help`` subprocess must not leak provider credentials
|
||||
via the inherited parent environment (third-party binary; same
|
||||
policy as the manifest probe and MCP spawn).
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="--no-overlay in help", stderr="")
|
||||
cua_backend._cua_driver_supports_no_overlay.cache_clear()
|
||||
cua_backend._cua_driver_supports_no_overlay("cua-driver")
|
||||
kwargs = mock_run.call_args.kwargs
|
||||
assert "env" in kwargs, (
|
||||
"subprocess.run was called without env= — cua-driver is a "
|
||||
"third-party binary and must not receive inherited secrets"
|
||||
)
|
||||
# The sanitized env must come from the same helper the MCP
|
||||
# spawn uses, so the policy is consistent across every
|
||||
# cua-driver invocation in this file.
|
||||
assert kwargs["env"] is not None
|
||||
|
||||
|
||||
class TestMcpInvocationUsesResolvedCommand:
|
||||
"""Surface 8 (NousResearch/hermes-agent#47072) + sweeper feedback
|
||||
#4701565902: when the manifest surfaces a relocated executable for
|
||||
``mcp_invocation.command``, the support probe must run against THAT
|
||||
binary, not the system-resolved ``_CUA_DRIVER_CMD``. Otherwise a
|
||||
wrapper/relocation with a different feature set either crashes on
|
||||
the unknown flag (when the probe falsely reports support) or
|
||||
silently keeps an unwanted overlay (when the probe falsely reports
|
||||
no support).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _fake_run(stdout: str = "", returncode: int = 0):
|
||||
from unittest.mock import MagicMock
|
||||
def _run(*args, **kwargs):
|
||||
proc = MagicMock()
|
||||
proc.stdout = stdout
|
||||
proc.returncode = returncode
|
||||
return proc
|
||||
return _run
|
||||
|
||||
def test_manifest_command_drives_support_probe(self):
|
||||
"""When the manifest returns a distinct command, the support
|
||||
probe runs against the manifest command, not the input
|
||||
``driver_cmd`` parameter.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from tools.computer_use.cua_backend import _resolve_mcp_invocation
|
||||
|
||||
manifest = (
|
||||
'{"mcp_invocation":'
|
||||
'{"command":"/opt/relocated/cua-driver","args":["mcp"]}}'
|
||||
)
|
||||
with patch("subprocess.run", new=self._fake_run(stdout=manifest)), \
|
||||
patch.object(cua_backend, "_cua_no_overlay", return_value=True), \
|
||||
patch.object(
|
||||
cua_backend, "_cua_driver_supports_no_overlay",
|
||||
return_value=True,
|
||||
) as mock_probe:
|
||||
cua_backend._cua_driver_supports_no_overlay.cache_clear()
|
||||
cmd, args = _resolve_mcp_invocation("/usr/bin/cua-driver")
|
||||
assert cmd == "/opt/relocated/cua-driver"
|
||||
# The support probe must be called with the manifest-resolved
|
||||
# command, not the input driver_cmd argument.
|
||||
mock_probe.assert_called_with("/opt/relocated/cua-driver")
|
||||
|
||||
def test_fallback_uses_input_driver_cmd_for_support_probe(self):
|
||||
"""When the manifest knows the args but NOT the command, the
|
||||
input ``driver_cmd`` parameter is what gets launched and
|
||||
probed.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from tools.computer_use.cua_backend import _resolve_mcp_invocation
|
||||
|
||||
manifest = '{"mcp_invocation":{"args":["mcp"]}}'
|
||||
with patch("subprocess.run", new=self._fake_run(stdout=manifest)), \
|
||||
patch.object(cua_backend, "_cua_no_overlay", return_value=True), \
|
||||
patch.object(
|
||||
cua_backend, "_cua_driver_supports_no_overlay",
|
||||
return_value=True,
|
||||
) as mock_probe:
|
||||
cua_backend._cua_driver_supports_no_overlay.cache_clear()
|
||||
cmd, args = _resolve_mcp_invocation("/my/local/cua-driver")
|
||||
assert cmd == "/my/local/cua-driver"
|
||||
# Fallback path: probe runs against the input driver_cmd.
|
||||
mock_probe.assert_called_with("/my/local/cua-driver")
|
||||
|
||||
def test_probe_distinguishes_support_between_binaries(self):
|
||||
"""Different binaries must produce independent support verdicts.
|
||||
The cache is keyed on ``driver_cmd``; the same cached result
|
||||
must not leak between the system binary and a manifest-relocated
|
||||
one.
|
||||
"""
|
||||
with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \
|
||||
patch.object(
|
||||
cua_backend, "_cua_driver_supports_no_overlay",
|
||||
side_effect=lambda cmd: cmd == "/opt/relocated/cua-driver",
|
||||
):
|
||||
# System binary does NOT support, manifest binary DOES.
|
||||
args = cua_backend._mcp_args_with_overlay_flag(
|
||||
["mcp"], driver_cmd="/usr/bin/cua-driver",
|
||||
)
|
||||
assert "--no-overlay" not in args
|
||||
args = cua_backend._mcp_args_with_overlay_flag(
|
||||
["mcp"], driver_cmd="/opt/relocated/cua-driver",
|
||||
)
|
||||
assert "--no-overlay" in args
|
||||
|
||||
|
||||
class TestMcpArgsOverlayFlag:
|
||||
def test_appended_when_enabled_and_supported(self):
|
||||
with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \
|
||||
patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True):
|
||||
result = cua_backend._mcp_args_with_overlay_flag(["mcp"])
|
||||
assert result == ["mcp", "--no-overlay"]
|
||||
|
||||
def test_not_appended_when_disabled(self):
|
||||
with patch.object(cua_backend, "_cua_no_overlay", return_value=False), \
|
||||
patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True):
|
||||
result = cua_backend._mcp_args_with_overlay_flag(["mcp"])
|
||||
assert result == ["mcp"]
|
||||
|
||||
def test_not_appended_when_driver_unsupported(self):
|
||||
with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \
|
||||
patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=False):
|
||||
result = cua_backend._mcp_args_with_overlay_flag(["mcp"])
|
||||
assert result == ["mcp"]
|
||||
|
||||
def test_does_not_mutate_original_list(self):
|
||||
original = ["mcp"]
|
||||
with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \
|
||||
patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True):
|
||||
result = cua_backend._mcp_args_with_overlay_flag(original)
|
||||
assert "--no-overlay" in result
|
||||
assert "--no-overlay" not in original
|
||||
|
|
@ -76,6 +76,11 @@ def test_update_check_sanitizes_env(monkeypatch):
|
|||
"latest_version": "1.0.0",
|
||||
"update_available": False,
|
||||
})
|
||||
# PATH is pinned to /usr/bin:/bin above, so the driver won't resolve;
|
||||
# pin it so the check reaches the (sanitized) subprocess spawn.
|
||||
monkeypatch.setattr(
|
||||
cua_backend, "resolve_cua_driver_cmd", lambda *a, **k: "cua-driver"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cua_backend.subprocess, "run", _capture_run(captured, stdout=payload)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ shape.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -323,3 +326,23 @@ class TestDriverCmdResolution:
|
|||
doctor.run_doctor()
|
||||
# First (and only) which call should have used the env var.
|
||||
which_mock.assert_called_with("/env/path/cua-driver")
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression")
|
||||
def test_user_local_driver_is_found_when_path_omits_it(self, tmp_path, monkeypatch):
|
||||
"""Doctor must inspect the same user-local driver as the runtime."""
|
||||
from tools.computer_use import doctor
|
||||
|
||||
driver = tmp_path / ".local" / "bin" / "cua-driver"
|
||||
driver.parent.mkdir(parents=True)
|
||||
driver.write_text("#!/bin/sh\nexit 0\n")
|
||||
driver.chmod(0o755)
|
||||
|
||||
monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
|
||||
with patch("tools.computer_use.doctor._drive_health_report", return_value=_ok_report()) as health, \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
assert doctor.run_doctor() == 0
|
||||
|
||||
health.assert_called_once_with(str(driver), include=(), skip=())
|
||||
|
|
|
|||
32
tests/computer_use/test_permissions_resolution.py
Normal file
32
tests/computer_use/test_permissions_resolution.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Regression tests for Computer Use readiness under a thin GUI PATH."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression")
|
||||
def test_status_finds_user_local_driver_when_path_omits_it(tmp_path, monkeypatch):
|
||||
"""Desktop status must agree with the runtime resolver, not bare PATH."""
|
||||
from tools.computer_use import permissions
|
||||
|
||||
driver = tmp_path / ".local" / "bin" / "cua-driver"
|
||||
driver.parent.mkdir(parents=True)
|
||||
driver.write_text("#!/bin/sh\nexit 0\n")
|
||||
driver.chmod(0o755)
|
||||
|
||||
monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
|
||||
with patch("tools.computer_use.permissions.sys.platform", "darwin"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "darwin"), \
|
||||
patch.object(permissions, "_run", return_value=MagicMock(stdout="0.0.0")), \
|
||||
patch.object(permissions, "_doctor", return_value={"ok": True, "checks": []}), \
|
||||
patch.object(permissions, "_mac_permissions"):
|
||||
status = permissions.computer_use_status()
|
||||
|
||||
assert status["installed"] is True
|
||||
|
|
@ -1228,7 +1228,7 @@ def test_computer_use_blank_custom_driver_command_falls_back_to_default():
|
|||
|
||||
|
||||
def test_computer_use_post_setup_respects_custom_driver_command_when_installed():
|
||||
"""post_setup already-installed checks should version-probe the override."""
|
||||
"""post_setup already-installed checks should version-probe the resolved override."""
|
||||
def fake_which(name: str):
|
||||
return "/opt/bin/custom-cua" if name == "custom-cua" else None
|
||||
|
||||
|
|
@ -1241,7 +1241,9 @@ def test_computer_use_post_setup_respects_custom_driver_command_when_installed()
|
|||
_run_post_setup("cua_driver")
|
||||
|
||||
run.assert_called_once()
|
||||
assert run.call_args.args[0] == ["custom-cua", "--version"]
|
||||
# Probe the resolved absolute path so thin GUI PATHs cannot reintroduce a
|
||||
# bare-command miss after HERMES_CUA_DRIVER_CMD was looked up via which().
|
||||
assert run.call_args.args[0] == ["/opt/bin/custom-cua", "--version"]
|
||||
|
||||
|
||||
def test_computer_use_post_setup_missing_override_does_not_accept_default_binary():
|
||||
|
|
|
|||
|
|
@ -148,6 +148,41 @@ class TestRegistration:
|
|||
patch("tools.computer_use.cua_backend.cua_driver_binary_available", return_value=False):
|
||||
assert cu_tool.check_computer_use_requirements() is False
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression")
|
||||
def test_check_fn_finds_user_local_cua_driver_when_path_omits_it(self, tmp_path, monkeypatch):
|
||||
"""Desktop/TUI launched from Finder/Dock can omit ~/.local/bin from PATH.
|
||||
|
||||
The cua-driver installer commonly places the binary there, so the
|
||||
registry check must still expose the computer_use tool schema.
|
||||
"""
|
||||
from tools.computer_use import tool as cu_tool
|
||||
|
||||
driver = tmp_path / ".local" / "bin" / "cua-driver"
|
||||
driver.parent.mkdir(parents=True)
|
||||
driver.write_text("#!/bin/sh\nexit 0\n")
|
||||
driver.chmod(0o755)
|
||||
|
||||
monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
|
||||
with patch("tools.computer_use.tool.sys.platform", "darwin"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "darwin"):
|
||||
assert cu_tool.check_computer_use_requirements() is True
|
||||
|
||||
def test_cua_driver_cmd_env_override_is_resolved_dynamically(self, tmp_path, monkeypatch):
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
driver = tmp_path / "custom-cua-driver"
|
||||
driver.write_text("#!/bin/sh\nexit 0\n")
|
||||
driver.chmod(0o755)
|
||||
|
||||
monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(driver))
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
|
||||
assert cua_backend.resolve_cua_driver_cmd() == str(driver)
|
||||
assert cua_backend.cua_driver_binary_available() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch & action routing
|
||||
|
|
@ -1198,6 +1233,16 @@ class TestUpdateCheck:
|
|||
no `check-update` verb, offline, an `error` payload, or unparseable output.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _driver_resolves(self):
|
||||
# The update check now short-circuits to None when no driver
|
||||
# resolves; CI has none installed, so pin a resolved path.
|
||||
with patch(
|
||||
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
return_value="/usr/local/bin/cua-driver",
|
||||
):
|
||||
yield
|
||||
|
||||
@staticmethod
|
||||
def _run_returning(stdout: str):
|
||||
fake = MagicMock()
|
||||
|
|
@ -1619,13 +1664,18 @@ class TestCuaDriverSessionReconnect:
|
|||
assert cli_calls == [("get_window_state", {"pid": 1, "window_id": 2})]
|
||||
assert result["images"] == ["B64PNG"]
|
||||
|
||||
def test_cli_fallback_reads_screenshot_from_file(self, tmp_path):
|
||||
def test_cli_fallback_reads_screenshot_from_file(self, tmp_path, monkeypatch):
|
||||
"""_call_tool_via_cli must base64-read a screenshot written to disk
|
||||
(screenshot_out_file path) when no inline base64 is present."""
|
||||
import base64 as _b64
|
||||
from typing import Any, cast
|
||||
from tools.computer_use.cua_backend import _CuaDriverSession
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
lambda: "/resolved/cua-driver",
|
||||
)
|
||||
|
||||
png_bytes = b"\x89PNG\r\n\x1a\nFAKEDATA"
|
||||
shot = tmp_path / "shot.png"
|
||||
shot.write_bytes(png_bytes)
|
||||
|
|
@ -1666,6 +1716,10 @@ class TestCuaDriverSessionReconnect:
|
|||
from tools.computer_use.cua_backend import _CuaDriverSession
|
||||
|
||||
session = cast(Any, _CuaDriverSession.__new__(_CuaDriverSession))
|
||||
monkeypatch.setattr(
|
||||
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
lambda: "/resolved/cua-driver",
|
||||
)
|
||||
|
||||
class FakeProc:
|
||||
stdout = '{"isError": true, "message": "bad target"}'
|
||||
|
|
@ -2331,8 +2385,8 @@ class TestCuaEnvironmentScrubbing:
|
|||
return MagicMock()
|
||||
|
||||
with patch.dict(os.environ, test_env, clear=True), \
|
||||
patch("tools.computer_use.cua_backend.cua_driver_binary_available",
|
||||
return_value=True), \
|
||||
patch("tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
return_value="cua-driver"), \
|
||||
patch("tools.computer_use.cua_backend._resolve_mcp_invocation",
|
||||
return_value=("cua-driver", ["mcp"])), \
|
||||
patch("mcp.StdioServerParameters", side_effect=capture_env), \
|
||||
|
|
@ -2390,6 +2444,30 @@ class TestCuaEnvironmentScrubbing:
|
|||
"At least one safe environment variable should be preserved"
|
||||
|
||||
|
||||
class TestCuaCliFallbackResolution:
|
||||
def test_cli_fallback_uses_resolved_driver_under_thin_path(self):
|
||||
"""CLI transport must use the same resolved path as MCP startup.
|
||||
|
||||
The CLI fallback runs after an MCP bridge error, precisely when a
|
||||
Finder/Dock-launched Desktop process may have a PATH without
|
||||
``~/.local/bin``. Falling back to the bare ``cua-driver`` command
|
||||
would reintroduce the original bug at runtime.
|
||||
"""
|
||||
from tools.computer_use.cua_backend import _AsyncBridge, _CuaDriverSession
|
||||
|
||||
proc = MagicMock(stdout="{}", stderr="", returncode=0)
|
||||
session = _CuaDriverSession(_AsyncBridge())
|
||||
with patch(
|
||||
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
return_value="/Users/example/.local/bin/cua-driver",
|
||||
), patch("subprocess.run", return_value=proc) as run:
|
||||
session._call_tool_via_cli("click", {"x": 1, "y": 2}, timeout=0.1)
|
||||
|
||||
assert run.call_args.args[0][:3] == [
|
||||
"/Users/example/.local/bin/cua-driver", "call", "click"
|
||||
]
|
||||
|
||||
|
||||
class TestClickButtonPassthrough:
|
||||
"""Surface 5 (NousResearch/hermes-agent#47072) — `middle_click` must
|
||||
actually reach cua-driver as a middle button, not silently degrade to
|
||||
|
|
@ -2795,6 +2873,13 @@ class TestMcpInvocationResolution:
|
|||
fields, wrong types) falls back to the literal `["mcp"]` baseline.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_overlay_off(self):
|
||||
"""Disable the --no-overlay flag so tests assert baseline args."""
|
||||
with patch("tools.computer_use.cua_backend._cua_no_overlay",
|
||||
return_value=False):
|
||||
yield
|
||||
|
||||
@staticmethod
|
||||
def _fake_run(stdout: str = "", returncode: int = 0, raises: Exception = None):
|
||||
"""Build a patched subprocess.run that yields the supplied result."""
|
||||
|
|
@ -2821,6 +2906,21 @@ class TestMcpInvocationResolution:
|
|||
assert cmd == "/opt/cua-driver"
|
||||
assert args == ["mcp"]
|
||||
|
||||
def test_manifest_bare_command_keeps_resolved_driver_path(self):
|
||||
"""A bare manifest command must not reintroduce the narrow-PATH bug."""
|
||||
from unittest.mock import patch
|
||||
from tools.computer_use.cua_backend import _resolve_mcp_invocation
|
||||
|
||||
manifest = (
|
||||
'{"mcp_invocation":'
|
||||
'{"command":"cua-driver","args":["mcp-stdio","--strict"]}}'
|
||||
)
|
||||
driver = "/Users/example/.local/bin/cua-driver"
|
||||
with patch("subprocess.run", new=self._fake_run(stdout=manifest)):
|
||||
cmd, args = _resolve_mcp_invocation(driver)
|
||||
assert cmd == driver
|
||||
assert args == ["mcp-stdio", "--strict"]
|
||||
|
||||
def test_future_renamed_subcommand_is_honored(self):
|
||||
"""The whole point: a future cua-driver that exposes `mcp-stdio`
|
||||
instead of `mcp` keeps working without a Hermes patch."""
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -137,7 +138,8 @@ def _action_result_from(
|
|||
# only have *looked* like it pinned. For a reproducible version, point
|
||||
# `HERMES_CUA_DRIVER_CMD` at a specific binary instead.
|
||||
|
||||
_CUA_DRIVER_CMD = os.environ.get("HERMES_CUA_DRIVER_CMD", "cua-driver")
|
||||
_CUA_DRIVER_CMD_ENV = "HERMES_CUA_DRIVER_CMD"
|
||||
_CUA_DRIVER_DEFAULT_CMD = "cua-driver"
|
||||
_CUA_DRIVER_ARGS = ["mcp"] # stdio MCP transport (fallback when the
|
||||
# driver doesn't expose `manifest` — see
|
||||
# `_resolve_mcp_invocation` below)
|
||||
|
|
@ -181,6 +183,36 @@ def _computer_use_cfg() -> Dict[str, Any]:
|
|||
return {}
|
||||
|
||||
|
||||
def _cua_no_overlay() -> bool:
|
||||
"""True when Hermes should pass ``--no-overlay`` to cua-driver.
|
||||
|
||||
Reads ``computer_use.no_overlay``. Default ``None`` (auto-detect):
|
||||
disable the overlay where idle CPU burn is a known failure mode —
|
||||
macOS (cursor-overlay vImage redraw loop, #28152/#47032), headless
|
||||
Linux / WSL2 / containers — and keep it on Windows / desktop Linux
|
||||
with a display. Explicit ``True`` / ``False`` overrides auto-detection.
|
||||
"""
|
||||
val = _computer_use_cfg().get("no_overlay")
|
||||
if val is not None:
|
||||
return bool(val)
|
||||
# Auto-detect: macOS overlay can peg a core indefinitely after a
|
||||
# computer_use session (#47032). Prefer off until the driver teardown
|
||||
# is solid; set computer_use.no_overlay: false to keep the cursor.
|
||||
if sys.platform == "darwin":
|
||||
return True
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
if not os.environ.get("DISPLAY"):
|
||||
return True
|
||||
try:
|
||||
with open("/proc/version", encoding="utf-8") as f:
|
||||
if "microsoft" in f.read().lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _cua_telemetry_disabled() -> bool:
|
||||
"""True when Hermes should disable cua-driver telemetry for this user.
|
||||
|
||||
|
|
@ -314,6 +346,13 @@ def _resolve_mcp_invocation(
|
|||
Falls back to ``(driver_cmd, ["mcp"])`` for older drivers that don't
|
||||
expose ``manifest``, or any indeterminate failure — the wrapper must
|
||||
not refuse to start just because the discovery hop failed.
|
||||
|
||||
When ``computer_use.no_overlay`` is enabled (or auto-detected on
|
||||
Linux), ``--no-overlay`` is appended to suppress the cursor overlay
|
||||
rendering loop that can consume CPU indefinitely when idle
|
||||
(#28152, #47032). Older drivers that don't recognise the flag will
|
||||
reject it; callers should fall back to the no-overlay invocation on
|
||||
spawn failure.
|
||||
"""
|
||||
try:
|
||||
from tools.environments.local import _sanitize_subprocess_env
|
||||
|
|
@ -327,28 +366,72 @@ def _resolve_mcp_invocation(
|
|||
env=_sanitize_subprocess_env(cua_driver_child_env()),
|
||||
)
|
||||
except Exception:
|
||||
return driver_cmd, list(_CUA_DRIVER_ARGS)
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd)
|
||||
out = (proc.stdout or "").strip()
|
||||
if proc.returncode != 0 or not out:
|
||||
return driver_cmd, list(_CUA_DRIVER_ARGS)
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd)
|
||||
try:
|
||||
manifest = json.loads(out)
|
||||
except (ValueError, TypeError):
|
||||
return driver_cmd, list(_CUA_DRIVER_ARGS)
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd)
|
||||
if not isinstance(manifest, dict):
|
||||
return driver_cmd, list(_CUA_DRIVER_ARGS)
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd)
|
||||
invocation = manifest.get("mcp_invocation")
|
||||
if not isinstance(invocation, dict):
|
||||
return driver_cmd, list(_CUA_DRIVER_ARGS)
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd)
|
||||
args = invocation.get("args")
|
||||
command = invocation.get("command")
|
||||
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
|
||||
return driver_cmd, list(_CUA_DRIVER_ARGS)
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd)
|
||||
if not isinstance(command, str) or not command:
|
||||
# The driver knows the subcommand but didn't surface its own path.
|
||||
# Keep our resolved driver_cmd; the args are still authoritative.
|
||||
return driver_cmd, args
|
||||
return command, args
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(args, driver_cmd=driver_cmd)
|
||||
if not _has_path_separator(command):
|
||||
# A manifest may legitimately retain the generic ``cua-driver`` name.
|
||||
# Under a GUI's thin PATH that would lose the resolved user-local path
|
||||
# and fail at MCP spawn, so preserve the concrete command we verified.
|
||||
return driver_cmd, _mcp_args_with_overlay_flag(args, driver_cmd=driver_cmd)
|
||||
# Manifest surfaced a relocated executable — probe THAT binary for
|
||||
# `--no-overlay` support rather than the system-resolved one, so a
|
||||
# wrapper/relocation with a different feature set doesn't crash on
|
||||
# an unknown flag (or silently keep an unwanted overlay).
|
||||
return command, _mcp_args_with_overlay_flag(args, driver_cmd=command)
|
||||
|
||||
|
||||
def _mcp_args_with_overlay_flag(
|
||||
args: List[str],
|
||||
driver_cmd: str = _CUA_DRIVER_DEFAULT_CMD,
|
||||
) -> List[str]:
|
||||
"""Return *args* with ``--no-overlay`` appended when configured and supported."""
|
||||
if _cua_no_overlay() and _cua_driver_supports_no_overlay(driver_cmd):
|
||||
return [*args, "--no-overlay"]
|
||||
return list(args)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _cua_driver_supports_no_overlay(driver_cmd: str) -> bool:
|
||||
"""True if the installed cua-driver recognises ``--no-overlay``.
|
||||
|
||||
Probes ``<driver> --help`` once and caches the result. Older
|
||||
drivers (< 0.6.x) reject unknown flags, so passing ``--no-overlay``
|
||||
would crash the MCP spawn.
|
||||
"""
|
||||
try:
|
||||
# cua-driver is a third-party binary — never hand it provider
|
||||
# API keys via inherited env (same policy as the manifest probe
|
||||
# and MCP spawn; #53503/#55709/#58889 lineage).
|
||||
from tools.environments.local import _sanitize_subprocess_env
|
||||
proc = subprocess.run(
|
||||
[driver_cmd, "--help"],
|
||||
capture_output=True, text=True, timeout=3.0,
|
||||
stdin=subprocess.DEVNULL,
|
||||
env=_sanitize_subprocess_env(cua_driver_child_env()),
|
||||
)
|
||||
help_text = (proc.stdout or "") + (proc.stderr or "")
|
||||
return "--no-overlay" in help_text
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Regex to parse element lines from get_window_state AX tree markdown.
|
||||
#
|
||||
|
|
@ -389,9 +472,68 @@ def _is_macos() -> bool:
|
|||
return sys.platform == "darwin"
|
||||
|
||||
|
||||
def _has_path_separator(value: str) -> bool:
|
||||
return os.sep in value or (os.altsep is not None and os.altsep in value)
|
||||
|
||||
|
||||
def _candidate_cua_driver_commands(override: Optional[str] = None) -> List[str]:
|
||||
"""Return candidate cua-driver commands in resolution order.
|
||||
|
||||
``override`` is authoritative when supplied. Otherwise a non-empty
|
||||
``HERMES_CUA_DRIVER_CMD`` is authoritative; only when neither is set do we
|
||||
use PATH and canonical install locations.
|
||||
|
||||
Desktop apps launched from Finder/Dock often inherit a narrow PATH that
|
||||
omits user-local install directories. The upstream cua-driver installer
|
||||
commonly places the binary under ``~/.local/bin`` on POSIX systems, so a
|
||||
Hermes Desktop/TUI session can otherwise filter out the `computer_use`
|
||||
tool even though `hermes computer-use doctor` succeeds from a login shell.
|
||||
"""
|
||||
configured = (override if override is not None else os.environ.get(_CUA_DRIVER_CMD_ENV, "")).strip()
|
||||
if configured:
|
||||
# An explicit override is authoritative: if it is wrong, report the
|
||||
# driver missing instead of silently picking a different binary.
|
||||
return [configured]
|
||||
|
||||
candidates = [_CUA_DRIVER_DEFAULT_CMD]
|
||||
home = os.path.expanduser("~")
|
||||
if sys.platform == "win32":
|
||||
candidates.extend([
|
||||
os.path.join(home, ".local", "bin", "cua-driver.exe"),
|
||||
os.path.join(home, ".local", "bin", "cua-driver"),
|
||||
])
|
||||
else:
|
||||
candidates.extend([
|
||||
os.path.join(home, ".local", "bin", "cua-driver"),
|
||||
os.path.join(home, ".cargo", "bin", "cua-driver"),
|
||||
"/opt/homebrew/bin/cua-driver",
|
||||
"/usr/local/bin/cua-driver",
|
||||
])
|
||||
return candidates
|
||||
|
||||
|
||||
def resolve_cua_driver_cmd(override: Optional[str] = None) -> Optional[str]:
|
||||
"""Resolve the cua-driver executable for every runtime/status surface.
|
||||
|
||||
A supplied override (or ``HERMES_CUA_DRIVER_CMD``) is never silently
|
||||
replaced by another binary. Otherwise resolve PATH first, then canonical
|
||||
user-local installation locations used by the official installer.
|
||||
"""
|
||||
for candidate in _candidate_cua_driver_commands(override):
|
||||
expanded = os.path.expanduser(candidate)
|
||||
if _has_path_separator(expanded):
|
||||
if shutil.which(expanded):
|
||||
return expanded
|
||||
else:
|
||||
resolved = shutil.which(expanded)
|
||||
if resolved:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def cua_driver_binary_available() -> bool:
|
||||
"""True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves."""
|
||||
return bool(shutil.which(_CUA_DRIVER_CMD))
|
||||
"""True if `cua-driver` resolves via env, PATH, or known install paths."""
|
||||
return resolve_cua_driver_cmd() is not None
|
||||
|
||||
|
||||
def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]]:
|
||||
|
|
@ -406,10 +548,13 @@ def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]]
|
|||
``error`` field is set), or the output didn't parse. Best-effort; never
|
||||
raises.
|
||||
"""
|
||||
driver_cmd = resolve_cua_driver_cmd()
|
||||
if not driver_cmd:
|
||||
return None
|
||||
try:
|
||||
from tools.environments.local import _sanitize_subprocess_env
|
||||
proc = subprocess.run(
|
||||
[_CUA_DRIVER_CMD, "check-update", "--json"],
|
||||
[driver_cmd, "check-update", "--json"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
# Some older drivers don't have the verb and fall through to a
|
||||
# stdin-reading mode rather than erroring — DEVNULL gives them EOF
|
||||
|
|
@ -763,14 +908,15 @@ class _CuaDriverSession:
|
|||
self._startup_phase = "binary-check"
|
||||
|
||||
try:
|
||||
if not cua_driver_binary_available():
|
||||
driver_cmd = resolve_cua_driver_cmd()
|
||||
if not driver_cmd:
|
||||
raise RuntimeError(cua_driver_install_hint())
|
||||
|
||||
# Surface 8: ask cua-driver itself which subcommand spawns
|
||||
# the MCP server, instead of hardcoding ["mcp"]. Falls back
|
||||
# transparently for older drivers / any discovery failure.
|
||||
self._startup_phase = "manifest-discovery"
|
||||
command, args = _resolve_mcp_invocation(_CUA_DRIVER_CMD)
|
||||
command, args = _resolve_mcp_invocation(driver_cmd)
|
||||
_t_manifest = _time.monotonic()
|
||||
params = StdioServerParameters(
|
||||
command=command,
|
||||
|
|
@ -1077,7 +1223,10 @@ class _CuaDriverSession:
|
|||
os.close(fd)
|
||||
call_args["screenshot_out_file"] = shot_file
|
||||
|
||||
cmd = [_CUA_DRIVER_CMD, "call", name, json.dumps(call_args)]
|
||||
driver_cmd = resolve_cua_driver_cmd()
|
||||
if not driver_cmd:
|
||||
raise RuntimeError(cua_driver_install_hint())
|
||||
cmd = [driver_cmd, "call", name, json.dumps(call_args)]
|
||||
attempts = 4
|
||||
backoff = 0.5
|
||||
parsed: Any = None
|
||||
|
|
@ -1432,17 +1581,26 @@ class CuaDriverBackend(ComputerUseBackend):
|
|||
except Exception as e:
|
||||
logger.debug("cua-driver start_session failed (continuing anonymous): %s", e)
|
||||
|
||||
# Cap screenshot size early so every later get_window_state / SOM
|
||||
# capture pays less over the daemon socket and in the model turn.
|
||||
# Only when the session handshake actually flipped `_started` —
|
||||
# otherwise call_tool would re-enter session.start() (see
|
||||
# Post-handshake session tuning. Both guard on `_started`: before the
|
||||
# handshake flips it, call_tool would re-enter session.start() (see
|
||||
# _LIFECYCLE_CALLS) and tests that stub start() would recurse.
|
||||
max_dim = _computer_use_max_image_dimension()
|
||||
if max_dim and self._session._started:
|
||||
try:
|
||||
self.set_config(max_image_dimension=max_dim)
|
||||
except Exception as e:
|
||||
logger.debug("cua-driver set_config(max_image_dimension) failed: %s", e)
|
||||
if self._session._started:
|
||||
# Cap screenshot size so every later get_window_state / SOM
|
||||
# capture pays less over the daemon socket and in the model turn.
|
||||
max_dim = _computer_use_max_image_dimension()
|
||||
if max_dim:
|
||||
try:
|
||||
self.set_config(max_image_dimension=max_dim)
|
||||
except Exception as e:
|
||||
logger.debug("cua-driver set_config(max_image_dimension) failed: %s", e)
|
||||
# Belt-and-suspenders when --no-overlay is unsupported or ignored:
|
||||
# hide the agent cursor overlay via the session API so macOS idle
|
||||
# redraw loops cannot keep burning CPU after the first action.
|
||||
if _cua_no_overlay():
|
||||
try:
|
||||
self.set_agent_cursor_enabled(False, cursor_id=self._session_id)
|
||||
except Exception as e:
|
||||
logger.debug("cua-driver set_agent_cursor_enabled failed: %s", e)
|
||||
|
||||
def stop(self) -> None:
|
||||
# Tear the cua-driver session down before disconnecting so the
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
|
@ -240,9 +239,8 @@ def run_doctor(
|
|||
) -> int:
|
||||
"""Resolve the cua-driver binary, call `health_report`, render the result.
|
||||
|
||||
Honors `HERMES_CUA_DRIVER_CMD` via the same `_cua_driver_cmd()` resolver
|
||||
that `install_cua_driver` + the runtime backend use, so the doctor
|
||||
diagnoses what your `computer_use` toolset will actually invoke.
|
||||
Honors `HERMES_CUA_DRIVER_CMD` via the shared runtime resolver, so the
|
||||
doctor diagnoses what your `computer_use` toolset will actually invoke.
|
||||
"""
|
||||
# Windows ships stdout/stderr wrapped with the system ANSI codec
|
||||
# (`cp1252` on a US locale, `cp936` on zh-CN, etc.). The check-matrix
|
||||
|
|
@ -255,16 +253,12 @@ def run_doctor(
|
|||
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
if driver_cmd is None:
|
||||
try:
|
||||
from hermes_cli.tools_config import _cua_driver_cmd
|
||||
driver_cmd = _cua_driver_cmd()
|
||||
except Exception:
|
||||
driver_cmd = os.environ.get("HERMES_CUA_DRIVER_CMD") or "cua-driver"
|
||||
from tools.computer_use.cua_backend import resolve_cua_driver_cmd
|
||||
|
||||
binary = shutil.which(driver_cmd)
|
||||
binary = resolve_cua_driver_cmd(driver_cmd)
|
||||
if not binary:
|
||||
print(f"cua-driver: not installed (looked for {driver_cmd!r}).")
|
||||
looked_for = driver_cmd or "cua-driver (PATH and canonical install paths)"
|
||||
print(f"cua-driver: not installed (looked for {looked_for!r}).")
|
||||
print(" Run: hermes computer-use install")
|
||||
return 2
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -35,15 +34,11 @@ _RUNTIME_PLATFORMS = frozenset({"darwin", "win32", "linux"})
|
|||
_BOOLS = ("accessibility", "screen_recording", "screen_recording_capturable")
|
||||
|
||||
|
||||
def _driver_cmd(override: Optional[str]) -> str:
|
||||
if override:
|
||||
return override
|
||||
try:
|
||||
from hermes_cli.tools_config import _cua_driver_cmd
|
||||
def _resolve_driver_cmd(override: Optional[str]) -> Optional[str]:
|
||||
"""Use the runtime resolver for UI status and permission commands too."""
|
||||
from tools.computer_use.cua_backend import resolve_cua_driver_cmd
|
||||
|
||||
return _cua_driver_cmd()
|
||||
except Exception:
|
||||
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
|
||||
return resolve_cua_driver_cmd(override)
|
||||
|
||||
|
||||
def _child_env() -> Dict[str, str]:
|
||||
|
|
@ -128,7 +123,7 @@ def computer_use_status(driver_cmd: Optional[str] = None) -> Dict[str, Any]:
|
|||
unknown (binary missing / probe failed). ``can_grant`` is macOS-only.
|
||||
"""
|
||||
plat = sys.platform
|
||||
binary = shutil.which(_driver_cmd(driver_cmd))
|
||||
binary = _resolve_driver_cmd(driver_cmd)
|
||||
out: Dict[str, Any] = {
|
||||
"platform": plat,
|
||||
"platform_supported": plat in _RUNTIME_PLATFORMS,
|
||||
|
|
@ -175,7 +170,7 @@ def request_permissions_grant(driver_cmd: Optional[str] = None) -> int:
|
|||
print("Computer Use permissions are a macOS concept; nothing to grant here.")
|
||||
return 64
|
||||
|
||||
binary = shutil.which(_driver_cmd(driver_cmd))
|
||||
binary = _resolve_driver_cmd(driver_cmd)
|
||||
if not binary:
|
||||
print("cua-driver: not installed. Run: hermes computer-use install")
|
||||
return 2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue