mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 11:02:03 +00:00
* feat(billing): nous_billing http client + BillingState core (phase 2b)
Phase 2b terminal-billing client foundation:
- hermes_cli/nous_billing.py: typed client for the 4 /api/billing/* endpoints
(state/charge/poll/auto-top-up). Raises typed errors (BillingScopeRequired,
BillingRateLimited, BillingAuthError) mapped from the live-verified contract;
fail-open is the caller's job. Idempotency-Key enforced client-side.
- agent/billing_view.py: surface-agnostic BillingState core + Decimal money
parsing (server emits decimal strings, not 2dp), fail-open builder,
idempotency-key gen, custom-amount validation.
- 51 unit tests (decimal parse/format, payload tiering, error->exception
matrix, fail-open, amount validation).
Plan: docs/plans/2026-06-13-001-phase-2b-terminal-billing-tui-plan.md
* feat(billing): billing:manage scope + lazy step-up re-auth (phase 2b)
- NOUS_BILLING_MANAGE_SCOPE constant.
- nous_token_has_billing_scope(): split-based scope check (no false-positive
substring match).
- step_up_nous_billing_scope(): re-runs the device flow requesting
billing:manage, reusing the held credential's portal/inference URLs + client_id
(so a preview stays a preview), persists like _login_nous but WITHOUT the model
picker. Returns True iff the minted token carries the scope (False when NAS
silently downscopes a non-admin / unticked grant).
Lazy step-up (plan D-A): normal login path unchanged; 403 insufficient_scope
from a billing call triggers this. 7 unit tests.
* feat(billing): billing JSON-RPC methods for the TUI (phase 2b)
billing.state / charge / charge_status / auto_reload / step_up in
tui_gateway/server.py. Return STRUCTURED success envelopes (result.ok +
result.error=<code>) rather than JSON-RPC-level errors, so the Ink rpc() promise
always resolves and the TUI branches on the typed billing error code
(insufficient_scope, rate_limited, no_payment_method, …) to render the right
affordance. Money serialized as decimal STRINGS + display strings. charge mints
+ echoes an idempotency_key for retry reuse. 16 unit tests.
* feat(billing): /billing CLI handler + command registry (phase 2b)
- CommandDef("billing", subcommands=buy|auto-reload|limit), added to
_SLACK_VIA_HERMES_ONLY so it routes via /hermes on Slack (keeps the 50-cap
parity test green, same as /credits).
- cli.py::_show_billing + screen helpers: all 5 screens (overview, buy→confirm→
poll, auto-reload, monthly-limit read-only). Reuses _prompt_text_input_modal /
_prompt_text_input (D-C). Non-interactive (_app is None) renders text + portal
deep-link, never prompts (R7). Decimal money end-to-end. 2s/5-min cancellable
poll loop; 429/503 = retry not failure; settled = ledger truth. Lazy step-up on
403 insufficient_scope. no_payment_method treated as mainline funnel-to-portal.
- 6 CLI tests; 156 command tests (incl. Slack/Telegram parity) green.
* feat(billing): /billing Ink TUI screens + tests (phase 2b)
- ui-tui/src/app/slash/commands/billing.ts: /billing TUI command covering all 5
screens — overview (text), buy <amt> → ConfirmReq → charge → non-blocking 2s/
5-min poll loop → settled/failed/timeout branches, auto-reload <below> <to> →
ConfirmReq → PATCH, limit (read-only). Reuses the existing ConfirmReq overlay
(D-C) — no bespoke component. Typed-error envelope branching: insufficient_scope
arms the lazy step-up confirm; no_payment_method/rate_limited/cap funnel to
portal. Client-side amount validation mirrors the server (bounds + 2dp).
- gatewayTypes.ts: Billing* response interfaces.
- registry.ts: register billingCommands.
- billingCommand.test.ts: 12 vitest cases (overview/gating/buy-confirm-poll-
settled/no_payment_method/step-up/limit/auto-reload/validation).
TUI build green; 12/12 vitest pass; slash tests pass once @hermes/ink is built.
* docs(billing): scrub private cross-repo references
NAS is a private repo — remove all references to it from the public PR:
- drop the cross-repo planning doc (planning scaffolding, not a deliverable;
the PR description documents the design)
- replace 'NAS' / 'PR #412 preview' mentions in code + test comments with
generic 'the server' / 'a preview deployment'
* docs(billing): scrub final NAS reference in step-up docstring
* docs(billing): drop dangling plan-doc refs
The phase-2b plan doc was removed in the cross-repo scrub (300afcc0b)
but two module docstrings still pointed at it. Drop the dead refs.
* feat(billing): interactive /billing overlay + step-up UX, portal-URL & token fixes
Adds the interactive /billing TUI overlay and hardens the terminal-billing
client across CLI and TUI.
- TUI: full /billing overlay state machine (overview to buy to confirm,
auto-reload, read-only monthly limit) reusing the existing confirm overlay.
- Step-up: surface the verification link in-transcript and open the browser
via the TUI's own opener (the device flow runs in the headless gateway, so a
printed URL was being dropped); run the step-up handler off the main loop and
emit the link as an out-of-band event so the gateway stays responsive.
- Step-up copy is scope-accurate ("Billing permission granted") and re-checks
/state so it never claims "enabled" when the org kill-switch is still off.
- Portal deep-links resolve to absolute URLs against the active portal base
(the server emits them relative) - fixes a bare "/billing?topup=open" link.
- Billing calls refresh an expired access token via the stored refresh token
instead of reporting a false "not logged in".
- Optimistic funnel: advise "set up a saved card on the portal" up front when
no card is on file (advisory, not a hard gate).
- Token resolution is cached briefly so the 2s charge poll loop stops
re-locking + re-reading the auth store on every tick; 401 re-resolves fresh.
- Remove the temporary demo-mode shims.
Validation: 87 Python billing tests, 88 TS tests (billing command + gateway
event handler), tsc clean, ink + ui-tui builds green.
* docs(billing): add /billing TUI screenshots for PR
* fix(cli): guard _last_invalidate on bare instances; update stale prompt-fallback test
The UI-invalidate throttle read self._last_invalidate unconditionally, which
raised AttributeError on HermesCLI instances built without __init__ (the
thread-safety test's object.__new__ shell). Guard the read with getattr.
The off-main-thread branch of _prompt_text_input was changed (#23185) to cancel
cleanly to None instead of falling back to a bare input() that would hang on the
slash-worker thread; the test still asserted the old direct-input fallback.
Update it to assert the current intended behavior: returns None, calls neither
run_in_terminal nor input(), and does not hang.
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import type { MouseTrackingMode, ScrollBoxHandle } from '@hermes/ink'
|
|
import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react'
|
|
|
|
import type { PasteEvent } from '../components/textInput.js'
|
|
import type { GatewayClient } from '../gatewayClient.js'
|
|
import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse } from '../gatewayTypes.js'
|
|
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
|
|
import type { RpcResult } from '../lib/rpc.js'
|
|
import type { Theme } from '../theme.js'
|
|
import type {
|
|
ApprovalReq,
|
|
ClarifyReq,
|
|
ConfirmReq,
|
|
DetailsMode,
|
|
Msg,
|
|
PanelSection,
|
|
SecretReq,
|
|
SectionVisibility,
|
|
SessionInfo,
|
|
SlashCatalog,
|
|
SudoReq,
|
|
Usage
|
|
} from '../types.js'
|
|
|
|
export interface StateSetter<T> {
|
|
(value: SetStateAction<T>): void
|
|
}
|
|
|
|
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
|
|
|
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
|
|
|
export type NoticeLevel = 'error' | 'info' | 'success' | 'warn'
|
|
|
|
// Credits/usage notice surfaced in the status bar. Shape is snake_case to
|
|
// match the gateway WS wire (`notification.show` payload) and the existing
|
|
// `Usage` type — no camelCase mapping layer. The `text` already carries its
|
|
// own leading glyph (⚠ • ✕ ✓) from the Python policy, so the renderer only
|
|
// colours it by `level` and never adds another glyph.
|
|
export interface Notice {
|
|
id?: string
|
|
key?: string
|
|
kind?: 'sticky' | 'ttl'
|
|
level?: NoticeLevel
|
|
text: string
|
|
ttl_ms?: null | number
|
|
}
|
|
|
|
// Single source of truth for indicator style names. Union type is
|
|
// derived from this tuple so adding/removing a style only touches one
|
|
// line — `useConfigSync` (validation) and `session.ts` (slash arg
|
|
// validation + usage hint) both import it.
|
|
export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const
|
|
export type IndicatorStyle = (typeof INDICATOR_STYLES)[number]
|
|
export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji'
|
|
|
|
export interface SelectionApi {
|
|
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
|
clearSelection: () => void
|
|
copySelection: () => Promise<string>
|
|
copySelectionNoClear: () => Promise<string>
|
|
getState: () => unknown
|
|
version: () => number
|
|
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
|
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
|
}
|
|
|
|
export interface CompletionItem {
|
|
display: string
|
|
meta?: string
|
|
text: string
|
|
}
|
|
|
|
export interface GatewayRpc {
|
|
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<null | T>
|
|
}
|
|
|
|
export interface GatewayServices {
|
|
gw: GatewayClient
|
|
rpc: GatewayRpc
|
|
}
|
|
|
|
export interface GatewayProviderProps {
|
|
children: ReactNode
|
|
value: GatewayServices
|
|
}
|
|
|
|
// ── Billing overlay (Phase 2b: full-modal TUI parity) ────────────────
|
|
// The /billing command no longer parses sub-commands; bare `/billing`
|
|
// fetches `billing.state` and opens this overlay. The overlay is a small
|
|
// state machine (overview → buy|autoreload|limit → confirm) that performs
|
|
// the SAME RPCs as the old slash flows (billing.charge / charge_status /
|
|
// auto_reload / step_up). Backend is unchanged & shared with the CLI.
|
|
|
|
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview'
|
|
|
|
/**
|
|
* The functions the overlay needs to talk to the gateway and emit
|
|
* transcript lines. Built once in `billing.ts` (closing over the live
|
|
* SlashRunCtx) and stashed in the overlay slot, mirroring how a ConfirmReq
|
|
* stashes its `onConfirm` closure. Keeps all RPC + error-mapping logic in
|
|
* billing.ts (single source of truth) — the overlay only renders + routes.
|
|
*/
|
|
export interface BillingOverlayCtx {
|
|
/** Run `billing.auto_reload` (enabled/threshold/top_up) → resolve ok/false. */
|
|
applyAutoReload: (enabled: boolean, threshold?: number, topUp?: number) => Promise<boolean>
|
|
/** Submit `billing.charge` for `amount` and poll to settlement (non-blocking). */
|
|
charge: (amount: string) => void
|
|
/** Open the portal in the browser + echo a transcript line. */
|
|
openPortal: (url: string) => void
|
|
/** Emit a transcript system line. */
|
|
sys: (text: string) => void
|
|
/** Validate a custom amount against state bounds + 2dp (mirrors the server). */
|
|
validate: (raw: string) => { amount?: string; error?: string }
|
|
}
|
|
|
|
/** Pending confirm built when leaving the buy/autoreload screen. */
|
|
export interface BillingPendingCharge {
|
|
amount: string
|
|
}
|
|
|
|
export interface BillingOverlayState {
|
|
ctx: BillingOverlayCtx
|
|
/** Set when on the 'confirm' screen for a buy. */
|
|
pendingCharge?: BillingPendingCharge | null
|
|
screen: BillingScreen
|
|
state: BillingStateResponse
|
|
}
|
|
|
|
export interface OverlayState {
|
|
agents: boolean
|
|
agentsInitialHistoryIndex: number
|
|
approval: ApprovalReq | null
|
|
billing: BillingOverlayState | null
|
|
clarify: ClarifyReq | null
|
|
confirm: ConfirmReq | null
|
|
modelPicker: boolean
|
|
pager: null | PagerState
|
|
pluginsHub: boolean
|
|
secret: null | SecretReq
|
|
sessions: boolean
|
|
skillsHub: boolean
|
|
sudo: null | SudoReq
|
|
}
|
|
|
|
export interface PagerState {
|
|
lines: string[]
|
|
offset: number
|
|
title?: string
|
|
}
|
|
|
|
export interface TranscriptRow {
|
|
index: number
|
|
key: string
|
|
msg: Msg
|
|
}
|
|
|
|
export interface UiState {
|
|
bgTasks: Set<string>
|
|
busy: boolean
|
|
busyInputMode: BusyInputMode
|
|
compact: boolean
|
|
detailsMode: DetailsMode
|
|
detailsModeCommandOverride: boolean
|
|
info: null | SessionInfo
|
|
liveSessionCount: number
|
|
inlineDiffs: boolean
|
|
mouseTracking: MouseTrackingMode
|
|
notice: Notice | null
|
|
pasteCollapseLines: number
|
|
pasteCollapseChars: number
|
|
|
|
sections: SectionVisibility
|
|
sessionTitle: string
|
|
showCost: boolean
|
|
showReasoning: boolean
|
|
indicatorStyle: IndicatorStyle
|
|
sid: null | string
|
|
status: string
|
|
statusBar: StatusBarMode
|
|
streaming: boolean
|
|
theme: Theme
|
|
usage: Usage
|
|
}
|
|
|
|
export interface VirtualHistoryState {
|
|
bottomSpacer: number
|
|
end: number
|
|
measureRef: (key: string) => (el: unknown) => void
|
|
offsets: ArrayLike<number>
|
|
start: number
|
|
topSpacer: number
|
|
}
|
|
|
|
export interface ComposerPasteResult {
|
|
cursor: number
|
|
value: string
|
|
}
|
|
|
|
export type MaybePromise<T> = Promise<T> | T
|
|
|
|
export interface ComposerActions {
|
|
clearIn: () => void
|
|
dequeue: () => string | undefined
|
|
enqueue: (text: string) => void
|
|
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
|
openEditor: () => Promise<void>
|
|
pushHistory: (text: string) => void
|
|
removeQueue: (index: number) => void
|
|
replaceQueue: (index: number, text: string) => void
|
|
setCompIdx: StateSetter<number>
|
|
setHistoryIdx: StateSetter<null | number>
|
|
setInput: StateSetter<string>
|
|
setInputBuf: StateSetter<string[]>
|
|
setPasteSnips: StateSetter<PasteSnippet[]>
|
|
setQueueEdit: (index: null | number) => void
|
|
syncQueue: () => void
|
|
}
|
|
|
|
export interface ComposerRefs {
|
|
historyDraftRef: MutableRefObject<string>
|
|
historyRef: MutableRefObject<string[]>
|
|
queueEditRef: MutableRefObject<null | number>
|
|
queueRef: MutableRefObject<string[]>
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
|
|
export interface ComposerState {
|
|
compIdx: number
|
|
compReplace: number
|
|
completions: CompletionItem[]
|
|
historyIdx: null | number
|
|
input: string
|
|
inputBuf: string[]
|
|
pasteSnips: PasteSnippet[]
|
|
queueEditIdx: null | number
|
|
queuedDisplay: string[]
|
|
}
|
|
|
|
export interface UseComposerStateOptions {
|
|
gw: GatewayClient
|
|
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
|
|
onImageAttached?: (info: ImageAttachResponse) => void
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
|
|
export interface UseComposerStateResult {
|
|
actions: ComposerActions
|
|
refs: ComposerRefs
|
|
state: ComposerState
|
|
}
|
|
|
|
export interface InputHandlerActions {
|
|
answerClarify: (answer: string) => void
|
|
appendMessage: (msg: Msg) => void
|
|
die: () => void
|
|
dispatchSubmission: (full: string) => void
|
|
guardBusySessionSwitch: (what?: string) => boolean
|
|
newSession: (msg?: string, title?: string) => void
|
|
sys: (text: string) => void
|
|
}
|
|
|
|
export interface InputHandlerContext {
|
|
actions: InputHandlerActions
|
|
composer: {
|
|
actions: ComposerActions
|
|
refs: ComposerRefs
|
|
state: ComposerState
|
|
}
|
|
gateway: GatewayServices
|
|
terminal: {
|
|
hasSelection: boolean
|
|
scrollRef: RefObject<null | ScrollBoxHandle>
|
|
scrollWithSelection: (delta: number) => void
|
|
selection: SelectionApi
|
|
stdout?: NodeJS.WriteStream
|
|
}
|
|
voice: {
|
|
enabled: boolean
|
|
recordKey: ParsedVoiceRecordKey
|
|
recording: boolean
|
|
setProcessing: StateSetter<boolean>
|
|
setRecording: StateSetter<boolean>
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
setVoiceTts: StateSetter<boolean>
|
|
}
|
|
wheelStep: number
|
|
}
|
|
|
|
export interface InputHandlerResult {
|
|
pagerPageSize: number
|
|
}
|
|
|
|
export interface GatewayEventHandlerContext {
|
|
composer: {
|
|
setInput: StateSetter<string>
|
|
}
|
|
gateway: GatewayServices
|
|
session: {
|
|
STARTUP_RESUME_ID: string
|
|
colsRef: MutableRefObject<number>
|
|
newSession: (msg?: string, title?: string) => void
|
|
// Set by useMainApp's exit handler to the session that was live when the
|
|
// gateway died unexpectedly; consumed once by the next `gateway.ready` so a
|
|
// respawn resumes that session instead of forging a fresh one.
|
|
recoverSidRef?: MutableRefObject<null | string>
|
|
resetSession: () => void
|
|
resumeById: (id: string) => void
|
|
setCatalog: StateSetter<null | SlashCatalog>
|
|
}
|
|
submission: {
|
|
submitRef: MutableRefObject<(value: string) => void>
|
|
}
|
|
system: {
|
|
bellOnComplete: boolean
|
|
stdout?: NodeJS.WriteStream
|
|
sys: (text: string) => void
|
|
}
|
|
transcript: {
|
|
appendMessage: (msg: Msg) => void
|
|
panel: (title: string, sections: PanelSection[]) => void
|
|
setHistoryItems: StateSetter<Msg[]>
|
|
}
|
|
voice: {
|
|
setProcessing: StateSetter<boolean>
|
|
setRecording: StateSetter<boolean>
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
setVoiceTts: StateSetter<boolean>
|
|
}
|
|
}
|
|
|
|
export interface SlashHandlerContext {
|
|
composer: {
|
|
enqueue: (text: string) => void
|
|
hasSelection: boolean
|
|
paste: (quiet?: boolean) => void
|
|
queueRef: MutableRefObject<string[]>
|
|
selection: SelectionApi
|
|
setInput: StateSetter<string>
|
|
}
|
|
gateway: GatewayServices
|
|
local: {
|
|
catalog: null | SlashCatalog
|
|
getHistoryItems: () => Msg[]
|
|
getLastUserMsg: () => string
|
|
maybeWarn: (value: unknown) => void
|
|
setCatalog: StateSetter<null | SlashCatalog>
|
|
}
|
|
session: {
|
|
closeSession: (targetSid?: null | string) => Promise<unknown>
|
|
die: () => void
|
|
dieWithCode: (code: number) => void
|
|
guardBusySessionSwitch: (what?: string) => boolean
|
|
newLiveSession: (msg?: string, title?: string) => void
|
|
newSession: (msg?: string, title?: string) => void
|
|
resetVisibleHistory: (info?: null | SessionInfo) => void
|
|
resumeById: (id: string) => void
|
|
setSessionStartedAt: StateSetter<number>
|
|
}
|
|
slashFlightRef: MutableRefObject<number>
|
|
transcript: {
|
|
page: (text: string, title?: string) => void
|
|
panel: (title: string, sections: PanelSection[]) => void
|
|
send: (text: string) => void
|
|
setHistoryItems: StateSetter<Msg[]>
|
|
sys: (text: string) => void
|
|
trimLastExchange: (items: Msg[]) => Msg[]
|
|
}
|
|
voice: {
|
|
setVoiceEnabled: StateSetter<boolean>
|
|
setVoiceRecordKey: (v: ParsedVoiceRecordKey) => void
|
|
setVoiceTts: StateSetter<boolean>
|
|
}
|
|
}
|
|
|
|
export interface AppLayoutActions {
|
|
answerApproval: (choice: string) => void
|
|
answerClarify: (answer: string) => void
|
|
answerSecret: (value: string) => void
|
|
answerSudo: (pw: string) => void
|
|
clearSelection: () => void
|
|
activateLiveSession: (id: string) => void
|
|
closeLiveSession: (id: string) => Promise<null | SessionCloseResponse>
|
|
newLiveSession: () => void
|
|
newPromptSession: (prompt: string, modelArg?: string) => void
|
|
onModelSelect: (value: string) => void
|
|
resumeById: (id: string) => void
|
|
setStickyPrompt: (value: string) => void
|
|
}
|
|
|
|
export interface AppLayoutComposerProps {
|
|
cols: number
|
|
compIdx: number
|
|
completions: CompletionItem[]
|
|
empty: boolean
|
|
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
|
input: string
|
|
inputBuf: string[]
|
|
pagerPageSize: number
|
|
queueEditIdx: null | number
|
|
queuedDisplay: string[]
|
|
submit: (value: string) => void
|
|
updateInput: StateSetter<string>
|
|
voiceRecordKey: ParsedVoiceRecordKey
|
|
}
|
|
|
|
export interface AppLayoutProgressProps {
|
|
showProgressArea: boolean
|
|
}
|
|
|
|
export interface AppLayoutStatusProps {
|
|
cwdLabel: string
|
|
goodVibesTick: number
|
|
lastTurnEndedAt: null | number
|
|
sessionStartedAt: null | number
|
|
showStickyPrompt: boolean
|
|
statusColor: string
|
|
stickyPrompt: string
|
|
turnStartedAt: null | number
|
|
voiceLabel: string
|
|
}
|
|
|
|
export interface AppLayoutTranscriptProps {
|
|
historyItems: Msg[]
|
|
scrollRef: RefObject<null | ScrollBoxHandle>
|
|
virtualHistory: VirtualHistoryState
|
|
virtualRows: TranscriptRow[]
|
|
}
|
|
|
|
export interface AppLayoutProps {
|
|
actions: AppLayoutActions
|
|
composer: AppLayoutComposerProps
|
|
mouseTracking: MouseTrackingMode
|
|
progress: AppLayoutProgressProps
|
|
status: AppLayoutStatusProps
|
|
transcript: AppLayoutTranscriptProps
|
|
}
|
|
|
|
export interface AppOverlaysProps {
|
|
cols: number
|
|
compIdx: number
|
|
completions: CompletionItem[]
|
|
onApprovalChoice: (choice: string) => void
|
|
onClarifyAnswer: (value: string) => void
|
|
onActiveSessionSelect: (sessionId: string) => void
|
|
onActiveSessionClose: (sessionId: string) => Promise<null | SessionCloseResponse>
|
|
onModelSelect: (value: string) => void
|
|
onNewLiveSession: () => void
|
|
onNewPromptSession: (prompt: string, modelArg?: string) => void
|
|
onResumeSelect: (sessionId: string) => void
|
|
onSecretSubmit: (value: string) => void
|
|
onSudoSubmit: (pw: string) => void
|
|
pagerPageSize: number
|
|
}
|
|
|
|
export interface PasteSnippet {
|
|
label: string
|
|
path?: string
|
|
text: string
|
|
}
|