Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
/**
* Composite backend scope keys for the multi-connection registry — shared
* between the Electron main process (backend pool keying) and the renderer
* (secondary socket registry), so both sides derive identical keys.
*
* The local/primary connection keeps the BARE profile key: every legacy pool
* entry, reaper log line, and touch call stays byte-identical for
* single-source users. Non-local connections get `conn:<id>::<profile>`,
* which cannot collide with a plain profile name (colons are invalid in
* profile names).
*/
export const LOCAL_CONNECTION_ID = 'local'
export function backendScopeKey(connectionId: null | string | undefined, profile: null | string | undefined): string {
const profileKey = String(profile ?? '').trim() || 'default'
const connection = String(connectionId ?? '').trim()
if (!connection || connection === LOCAL_CONNECTION_ID) {
return profileKey
}
return `conn:${connection}::${profileKey}`
}
/** Scope a registry route without collapsing its explicit `local` source id.
* Null/empty ids still identify the legacy profile-only route. */
export function registryBackendScopeKey(
connectionId: null | string | undefined,
profile: null | string | undefined
): string {
const profileKey = String(profile ?? '').trim() || 'default'
const connection = String(connectionId ?? '').trim()
return connection ? `conn:${connection}::${profileKey}` : profileKey
}
/** All pool keys owned by a connection share this prefix (teardown on remove). */
export function backendScopePrefix(connectionId: string): string {
return `conn:${String(connectionId).trim()}::`
}
@@ -0,0 +1,24 @@
/**
* Compile-time guard for BillingPaymentMethod.
*
* There is nothing to run here — the point is that `tsc` accepts this file.
* An earlier revision typed the fallback arm's `kind` as `string & {}`, which
* makes the discriminant non-literal and silently defeats narrowing for every
* arm: the `pm.brand` read below stops compiling. Keeping this file honest
* keeps `kind` narrowable.
*/
import type { BillingPaymentMethod } from './billing-types'
export function describePaymentMethod(pm: BillingPaymentMethod): string {
switch (pm.kind) {
case 'card':
return pm.wallet ? `${pm.wallet} ${pm.brand} ${pm.last4}` : `${pm.brand} ${pm.last4}`
case 'link':
return pm.email ?? 'Link'
case 'unknown':
return pm.raw_kind
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { KnownBillingRefusalCode } from './billing-types.js'
export type BillingRecovery = 'login' | 'none' | 'portal' | 'reconnect' | 'retry' | 'step_up'
export interface BillingRefusalPolicy {
recovery: BillingRecovery
ambiguousMidPoll?: true
reuseIdempotencyKey?: true
}
export const BILLING_REFUSAL_POLICY: Record<KnownBillingRefusalCode, BillingRefusalPolicy> = {
auto_top_up_disabled_failures: { recovery: 'portal' },
cli_billing_disabled: { recovery: 'portal' },
consent_required: { recovery: 'portal' },
endpoint_unavailable: { recovery: 'retry', reuseIdempotencyKey: true },
idempotency_conflict: { recovery: 'none' },
idempotency_key_required: { recovery: 'none' },
// Deliberate: losing scope mid-poll cannot undo an accepted charge, so its outcome is unknown.
insufficient_scope: { recovery: 'step_up', ambiguousMidPoll: true },
internal_error: { recovery: 'retry' },
invalid_charge_id: { recovery: 'none' },
invalid_request: { recovery: 'none' },
monthly_cap_exceeded: { recovery: 'portal' },
network_error: { recovery: 'retry', reuseIdempotencyKey: true },
no_payment_method: { recovery: 'portal' },
org_access_denied: { recovery: 'portal' },
preview_rejected: { recovery: 'none' },
rate_limited: { recovery: 'retry', reuseIdempotencyKey: true },
remote_spending_disabled: { recovery: 'portal' },
remote_spending_revoked: { recovery: 'reconnect', ambiguousMidPoll: true },
role_required: { recovery: 'portal' },
session_revoked: { recovery: 'login', ambiguousMidPoll: true },
stripe_unavailable: { recovery: 'retry', reuseIdempotencyKey: true },
temporarily_unavailable: { recovery: 'retry', reuseIdempotencyKey: true },
upgrade_cap_exceeded: { recovery: 'none' },
validation_failed: { recovery: 'none' }
}
export function refusalPolicy(code: string): BillingRefusalPolicy {
if (Object.hasOwn(BILLING_REFUSAL_POLICY, code)) {
return BILLING_REFUSAL_POLICY[code as KnownBillingRefusalCode]
}
// Unknown codes must still show the server message; no special handling is the safe fallback.
return { recovery: 'none' }
}
+363
View File
@@ -0,0 +1,363 @@
/**
* Shared Remote Spending wire contracts.
*
* These shapes round-trip between the Python tui_gateway and TypeScript clients
* such as the TUI and desktop app. Keep rendering state, client logic, and the
* gateway event union out of this runtime-free module.
*/
// ── Billing wall (inference credit exhaustion) ───────────────────────
/**
* Structured billing-wall descriptor emitted by the gateway on the
* `message.complete` event (`payload.billing`) when an inference call fails
* because the account is out of credits / payment is required — mirrors the
* Python `agent/billing_links.py::BillingBlock`.
*
* Detection is backend-only (`agent/error_classifier.py` →
* `FailoverReason.billing`), so every surface renders from this one signal and
* never re-classifies free-form error text. `is_nous` routes recovery: Nous is
* the managed route with in-app billing (desktop Settings → Billing, TUI
* `/topup`), while third-party providers deep-link to `billing_url`.
*/
export interface BillingBlock {
provider: string
provider_label: string
model: string
billing_url: string | null
is_nous: boolean
message: string
}
// ── Remote Spending (Phase 2b) ───────────────────────────────────────
/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */
export interface UsageBarData {
kind: 'plan' | 'topup'
remaining_display: string
total_display: string
spent_display: string
pct_used: null | number
fill_fraction: number
}
/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */
export interface UsageModelData {
available: boolean
status?: string
plan_name?: null | string
renews_at?: null | string
renews_display?: null | string
subscription_remaining_display?: null | string
topup_remaining_display?: null | string
total_spendable_display?: null | string
has_topup?: boolean
plan_bar?: null | UsageBarData
topup_bar?: null | UsageBarData
}
/**
* The closed set of refusal/error codes the gateway serializes today
* (`_serialize_billing_error` preserves the raw NAS code where one exists,
* plus the client-originated transport codes). Closed on purpose: an
* exhaustive `Record<KnownBillingRefusalCode, …>` (classification tables,
* copy maps, tests) gets a compile error when a code is added here but not
* mapped.
*/
export type KnownBillingRefusalCode =
| 'auto_top_up_disabled_failures'
| 'cli_billing_disabled'
| 'consent_required'
| 'endpoint_unavailable'
| 'idempotency_conflict'
| 'idempotency_key_required'
| 'insufficient_scope'
| 'internal_error'
| 'invalid_charge_id'
| 'invalid_request'
| 'monthly_cap_exceeded'
| 'network_error'
| 'no_payment_method'
| 'org_access_denied'
| 'preview_rejected'
| 'rate_limited'
| 'remote_spending_disabled'
| 'remote_spending_revoked'
| 'role_required'
| 'session_revoked'
| 'stripe_unavailable'
| 'temporarily_unavailable'
| 'upgrade_cap_exceeded'
| 'validation_failed'
/**
* What the wire actually carries: a known code, or an unknown future one
* (e.g. the NAS W3 card-health family). The `(string & {})` arm keeps unknown
* codes assignable — consumers must keep an unknown-code fallback branch.
*/
export type BillingRefusalCode = KnownBillingRefusalCode | (string & {})
/**
* The closed set of terminal reasons a settled-poll charge can fail with (NAS
* `cli-charge-failure-reason.ts` — all four values), plus the raw Stripe code
* NAS pre-#711 leaks for SCA-on-upgrade.
*/
export type KnownChargeFailureReason =
| 'authentication_required'
| 'card_declined'
| 'payment_method_expired'
| 'processing_error'
| 'subscription_payment_intent_requires_action'
/** Wire shape: a known reason or an unknown future one; degrade safely. */
export type ChargeFailureReason = KnownChargeFailureReason | (string & {})
export interface BillingCardInfo {
brand: string
last4: string
masked: string
/** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */
display?: string
/** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */
resolved_via?: null | string
}
/**
* The org's payment method on file.
*
* This is the authoritative field. `card` is a lossy older view of the same
* thing: it is populated only when the method is a card, and is null for
* every other kind — so `!card` does NOT mean "no payment method on file".
* A surface that gates on `card` alone will tell a Link customer they have
* nothing on file.
*
* Older gateways omit this field entirely, so absence means "this gateway
* didn't say", not "nothing on file".
*
* A kind this client predates arrives as `unknown` rather than as its real
* name, which keeps `kind` narrowable — every arm is a literal, so
* `if (pm.kind === 'card')` gives you the card fields. (The `string & {}`
* trick used by BillingRefusalCode does not work here: on an object union it
* makes the discriminant non-literal and defeats narrowing for every arm.)
*/
export type BillingPaymentMethod =
| {
kind: 'card'
brand: string
last4: string
/** Wallet that wrapped the card (e.g. "apple_pay", "google_pay"), if any. */
wallet: string | null
/** Card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null. */
resolved_via: null | string
}
| {
kind: 'link'
/** Link displays as the account email; can be absent on the Stripe side. */
email: null | string
resolved_via: null | string
}
| {
kind: 'unknown'
/** What the server actually called it, for logs and neutral copy. */
raw_kind: string
resolved_via: null | string
}
export interface BillingMonthlyCap {
is_default_ceiling: boolean
limit_display: string
limit_usd: string | null
spent_display: string
spent_this_month_usd: string | null
}
export interface BillingAutoReload {
// The gateway's _parse_auto_reload_card returns None for a missing/unknown-kind
// card, and _serialize_billing_state emits `card: null` — so the wire really can
// carry null. Consumers must keep a null branch (treat it like the canonical card).
card:
| { kind: 'canonical' }
| {
kind: 'distinct'
payment_method_id: string
brand: string | null
last4: string | null
}
| { kind: 'none' }
| null
enabled: boolean
reload_to_display: string
reload_to_usd: string | null
threshold_display: string
threshold_usd: string | null
}
export interface BillingStateResponse {
auto_reload: BillingAutoReload | null
balance_display: string
balance_usd: string | null
// NAS capability (canChangePlan) when the server sends it; legacy role fallback otherwise
can_change_plan?: boolean
can_charge: boolean
card: BillingCardInfo | null
// Typed payment-method union (newer gateways only); `card` remains the
// compatibility field and stays populated for kind "card".
payment_method?: BillingPaymentMethod | null
charge_presets: string[]
charge_presets_display: string[]
cli_billing_enabled: boolean
error?: string | null
is_admin: boolean
logged_in: boolean
max_usd: string | null
min_usd: string | null
monthly_cap: BillingMonthlyCap | null
ok: boolean
org_name: string | null
portal_url: string | null
role: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so /topup
// renders the same bars as /usage and /subscription from this single fetch.
usage?: UsageModelData
}
/**
* Raw error payload echoed from the server (`_serialize_billing_error`). Carries
* the extra fields a few error codes attach — notably `remainingUsd` on
* `monthly_cap_exceeded` — so the client can render the same detail the CLI does.
*/
export interface BillingErrorPayload {
isDefaultCeiling?: boolean
remainingUsd?: string
}
export interface BillingChargeResponse {
actor?: string
charge_id?: string
code?: string
error?: BillingRefusalCode
idempotency_key?: string
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
export interface BillingChargeStatusResponse {
amount_usd?: string | null
error?: BillingRefusalCode
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
reason?: ChargeFailureReason | null
retry_after?: number | null
settled_at?: string | null
status?: string
}
export interface BillingMutationResponse {
actor?: string
code?: string
error?: BillingRefusalCode
granted?: boolean
message?: string
ok: boolean
/**
* On ok:false, the structured error payload. On ok:true the gateway passes
* through the raw NAS success body (e.g. rail, changeType, cancelAtPeriodEnd
* for subscription mutations), which has no stable shape here.
*/
payload?: BillingErrorPayload | Record<string, unknown>
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
export interface SubscriptionTierOption {
tier_id: string
name: string
tier_order: number // sorts the picker + upgrade/downgrade hint
dollars_per_month_display: string // pre-formatted ($X / $X.YY)
monthly_credits: string | null
is_current: boolean // the active plan: shown, not selectable
is_enabled: boolean // false = grandfathered current tier
}
export interface SubscriptionStateResponse {
ok: boolean
logged_in: boolean
is_admin: boolean
can_change_plan: boolean // NAS capability (canChangePlan: OWNER/ADMIN/FINANCE_ADMIN); legacy role fallback when the server omits it
org_name: string | null
org_id: string | null // org.id from the NAS response
role: string | null
context: 'personal' | 'team' // personal account vs team/org terminal
current: {
tier_id: string | null // null = free (no active sub)
tier_name: string | null
monthly_credits: string | null
credits_remaining: string | null
cycle_ends_at: string | null // ISO
pending_downgrade_tier_name: string | null
pending_downgrade_at: string | null
pending_downgrade_display: string | null // formatted pending_downgrade_at
cancel_at_period_end: boolean // subscription scheduled to cancel at period end
cancellation_effective_at: string | null // ISO when cancellation takes effect
cancellation_effective_display: string | null // formatted cancellation_effective_at
} | null
tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker
portal_url: string | null
error?: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so the
// overlay renders the same bars as /usage from this single fetch.
usage?: UsageModelData
}
// A chargeless quote (POST /subscription/preview) of what a change would do.
// `effect` drives the confirm copy; a failed preview reuses the typed-error
// envelope fields (same as the mutations) so a 403 still triggers the step-up.
export interface SubscriptionPreviewResponse {
ok: boolean
effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked'
reason?: string | null
current_tier_id?: string | null
current_tier_name?: string | null
target_tier_id?: string | null
target_tier_name?: string | null
monthly_credits_delta?: string | null
amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade
effective_at?: string | null // ISO, when a scheduled change lands
// typed-error envelope (present when ok=false)
error?: BillingRefusalCode
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}
// The single money route (POST /subscription/upgrade). `status` distinguishes a
// completed upgrade from an SCA/decline that must finish in the portal at
// `recovery_url`. `idempotency_key` is echoed so a retry reuses it.
export interface SubscriptionUpgradeResponse {
ok: boolean
status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed'
target_tier_name?: string | null
recovery_url?: string | null
reason?: ChargeFailureReason | null
idempotency_key?: string
// typed-error envelope (present when ok=false)
error?: BillingRefusalCode
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}
+89
View File
@@ -0,0 +1,89 @@
import { refusalPolicy } from './billing-policy.js'
import type { BillingChargeStatusResponse } from './billing-types.js'
export interface SettlementDeps {
fetchStatus(): Promise<BillingChargeStatusResponse>
sleep(ms: number): Promise<void>
isCancelled(): boolean
now(): number
}
export type SettlementOutcome =
| { kind: 'settled'; status: BillingChargeStatusResponse }
| { kind: 'failed'; status: BillingChargeStatusResponse }
| { kind: 'refused'; error: string; status: BillingChargeStatusResponse }
| {
kind: 'ambiguous'
error: string
status?: BillingChargeStatusResponse
cause?: unknown
}
| { kind: 'timed_out' }
| { kind: 'cancelled' }
export const SETTLEMENT_POLL_INTERVAL_MS = 2000
export const SETTLEMENT_POLL_CAP_MS = 5 * 60 * 1000
export const SETTLEMENT_MAX_RETRY_AFTER_MS = 30000
export async function driveChargeSettlement(deps: SettlementDeps): Promise<SettlementOutcome> {
const start = deps.now()
const timedOut = (): boolean => deps.now() - start >= SETTLEMENT_POLL_CAP_MS
while (true) {
if (deps.isCancelled()) {
return { kind: 'cancelled' }
}
let status: BillingChargeStatusResponse
try {
status = await deps.fetchStatus()
} catch (cause) {
return { kind: 'ambiguous', error: 'transport', cause }
}
if (!status.ok) {
const error = status.error ?? ''
const policy = refusalPolicy(error)
if (policy.ambiguousMidPoll) {
return { kind: 'ambiguous', error: error || 'unknown', status }
}
if (policy.recovery === 'retry') {
if (timedOut()) {
return { kind: 'timed_out' }
}
const wait = Math.min(
(status.retry_after ?? 5) * 1000,
SETTLEMENT_MAX_RETRY_AFTER_MS
)
await deps.sleep(wait)
continue
}
return {
kind: 'refused',
error: status.error ?? status.message ?? 'error',
status
}
}
if (status.status === 'settled') {
return { kind: 'settled', status }
}
if (status.status === 'failed') {
return { kind: 'failed', status }
}
if (timedOut()) {
return { kind: 'timed_out' }
}
await deps.sleep(SETTLEMENT_POLL_INTERVAL_MS)
}
}
@@ -0,0 +1,40 @@
export interface CronTriggerRunResult<T> {
started: boolean
value: T | null
}
export interface CronTriggerController {
isRunning(key: string): boolean
run<T>(key: string, action: () => Promise<T>, onStarted?: () => void): Promise<CronTriggerRunResult<T>>
}
// This is an interaction guard for one mounted UI surface. Cross-window and
// cross-process exclusion remains the backend's responsibility via its durable
// cron claim; a renderer-local Set must never be treated as the execution lock.
export function createCronTriggerController(
onRunningChange: (key: string, running: boolean) => void = () => undefined
): CronTriggerController {
const running = new Set<string>()
return {
isRunning: key => running.has(key),
async run<T>(key: string, action: () => Promise<T>, onStarted?: () => void) {
if (running.has(key)) {
return { started: false, value: null }
}
running.add(key)
try {
onRunningChange(key, true)
onStarted?.()
return { started: true, value: await action() }
} finally {
running.delete(key)
onRunningChange(key, false)
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Size cap for local files Desktop loads as data URLs (composer attach, image
* preview, …).
*
* Main owns the persisted value; the renderer mirrors it in Settings → Chat and
* clamps optimistically before sending. Both ends have to agree on the default
* and the bounds, so they live here rather than as two constants with a
* "keep these in sync" comment between them.
*
* The whole file is base64-buffered in main, so this is a memory guard, not a
* model limit. The ceiling is only a typo guard — values well below it can
* still OOM the app.
*/
export const DATA_URL_READ_DEFAULT_MAX_MB = 16
export const DATA_URL_READ_MIN_MAX_MB = 1
export const DATA_URL_READ_MAX_MAX_MB = 4096
export function clampDataUrlReadMaxMb(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return DATA_URL_READ_DEFAULT_MAX_MB
}
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
}
+114
View File
@@ -0,0 +1,114 @@
export { backendScopeKey, backendScopePrefix, LOCAL_CONNECTION_ID, registryBackendScopeKey } from './backend-scope'
export {
BILLING_REFUSAL_POLICY,
type BillingRecovery,
type BillingRefusalPolicy,
refusalPolicy
} from './billing-policy'
export type {
BillingAutoReload,
BillingBlock,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingPaymentMethod,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
KnownBillingRefusalCode,
KnownChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
SubscriptionUpgradeResponse,
UsageBarData,
UsageModelData
} from './billing-types'
export {
driveChargeSettlement,
SETTLEMENT_MAX_RETRY_AFTER_MS,
SETTLEMENT_POLL_CAP_MS,
SETTLEMENT_POLL_INTERVAL_MS,
type SettlementDeps,
type SettlementOutcome
} from './charge-settlement'
export {
createCronTriggerController,
type CronTriggerController,
type CronTriggerRunResult
} from './cron-trigger-controller'
export {
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB
} from './data-url-read-max'
export {
type ConnectionState,
type GatewayClientOptions,
type GatewayEvent,
type GatewayEventName,
type GatewayRequestId,
type JsonRpcErrorPayload,
type JsonRpcFrame,
JsonRpcGatewayClient,
JsonRpcGatewayError,
type WebSocketLike
} from './json-rpc-gateway'
export { skillInvocationText } from './skill-scaffold'
export {
type HermesSkin,
SKIN_BRANDING_TOKENS,
SKIN_COLOR_TOKENS,
type SkinBranding,
type SkinBrandingToken,
type SkinColors,
type SkinColorToken
} from './skin'
export {
backgroundMaterialFor,
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
GLASS_MATERIALS,
GLASS_SCOPES,
glassActive,
type GlassMaterial,
glassMaterialForPicker,
glassMaterialsFor,
type GlassScope,
glassSupportedOn,
glassSurfaceKeep,
normalizeMaterial,
normalizeMode,
normalizeScope,
normalizeState,
TRANSLUCENCY_CURVE,
TRANSLUCENCY_MAX,
TRANSLUCENCY_MIN,
TRANSLUCENCY_OPACITY_FLOOR,
TRANSLUCENCY_STEP,
type TranslucencyMode,
type TranslucencyState,
translucencySupportedOn,
vibrancyFor,
windowOpacityFor,
WINDOWS_BACKGROUND_MATERIALS,
WINDOWS_GLASS_MIN_BUILD,
type WindowsBackgroundMaterial
} from './translucency'
export {
buildHermesWebSocketUrl,
type GatewayAuthMode,
GatewayReauthRequiredError,
type GatewayWsConnection,
type GatewayWsUrlResult,
type HermesWebSocketUrlOptions,
isGatewayReauthRequired,
resolveGatewayWsUrl,
type ResolveGatewayWsUrlDeps,
type WebSocketAuthParam
} from './websocket-url'
@@ -0,0 +1,336 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { JsonRpcGatewayClient } from './json-rpc-gateway'
/**
* Minimal EventTarget-based WebSocket stand-in so the seq-tracking and
* replay-resume logic can be driven with real dispatch semantics.
*/
class FakeWebSocket extends EventTarget {
static OPEN = 1
static instances: FakeWebSocket[] = []
readyState = 0
sent: string[] = []
url: string
constructor(url: string) {
super()
this.url = url
FakeWebSocket.instances.push(this)
}
send(data: string): void {
this.sent.push(data)
}
close(): void {
this.readyState = 3
this.dispatchEvent(new CloseEvent('close'))
}
// Test drivers
open(): void {
this.readyState = 1
this.dispatchEvent(new Event('open'))
}
serverFrame(obj: unknown): void {
this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(obj) }))
}
lastRequest(): { id: string; method: string; params: Record<string, unknown> } {
const last = this.sent[this.sent.length - 1]
return JSON.parse(last ?? '{}')
}
}
let sockets: FakeWebSocket[]
const makeClient = () => {
const client = new JsonRpcGatewayClient({
socketFactory: url => new FakeWebSocket(url) as unknown as WebSocket,
heartbeatIntervalMs: 0,
heartbeatDeadlineMs: 0,
connectTimeoutMs: 1000
})
return client
}
describe('JsonRpcGatewayClient event-seq tracking + replay resume', () => {
beforeEach(() => {
FakeWebSocket.instances = []
sockets = FakeWebSocket.instances as unknown as FakeWebSocket[]
})
it('records per-session seq watermarks from live events', async () => {
const client = makeClient()
const p = client.connect('ws://x')
sockets[0].open()
await p
sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 4 } })
sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 2 } }) // out of order / late
sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'tool.start', session_id: 's2', seq: 9 } })
sockets[0].serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'skin.changed' } }) // no sid/seq
expect(client.getSeqWatermarks()).toEqual({ s1: 4, s2: 9 })
client.close()
})
it('fetches replay on reconnect for sessions it has watermarks for', async () => {
const client = makeClient()
const first = client.connect('ws://x')
let sock = sockets[sockets.length - 1]
sock.open()
await first
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.start', session_id: 's1', seq: 1 } })
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 5 } })
// Drop and reconnect.
client.invalidate('drop')
const second = client.connect('ws://x')
sock = sockets[sockets.length - 1]
sock.open()
await second
// The reconnect triggered a replay fetch — flush microtasks.
await vi.waitFor(() => {
const req = sock.lastRequest()
expect(req.method).toBe('session.events.since')
expect(req.params).toMatchObject({ session_id: 's1', last_seen: 5 })
})
client.close()
})
it('dispatches replayed events through the normal handler path', async () => {
const client = makeClient()
const seen: string[] = []
client.on('tool.complete', e => seen.push(`live:${String((e.payload as { n?: number }).n)}`))
const first = client.connect('ws://x')
let sock = sockets[sockets.length - 1]
sock.open()
await first
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 3 } })
client.invalidate('drop')
const second = client.connect('ws://x')
sock = sockets[sockets.length - 1]
sock.open()
await second
await vi.waitFor(async () => {
const req = sock.lastRequest()
expect(req.method).toBe('session.events.since')
// Answer the replay request with two missed events.
sock.serverFrame({
jsonrpc: '2.0',
id: req.id,
result: {
events: [
{ type: 'tool.complete', session_id: 's1', seq: 4, payload: { n: 1 } },
{ type: 'tool.complete', session_id: 's1', seq: 5, payload: { n: 2 } }
],
latest_seq: 5,
truncated: false,
count: 2
}
})
await Promise.resolve()
expect(seen).toEqual(['live:1', 'live:2'])
})
expect(client.getSeqWatermarks().s1).toBe(5)
client.close()
})
it('does not attempt replay when nothing was ever observed', async () => {
const client = makeClient()
const p = client.connect('ws://x')
sockets[0].open()
await p
// No events ever seen → close+reconnect must NOT fire a replay RPC.
client.invalidate('drop')
const p2 = client.connect('ws://x')
sockets[sockets.length - 1].open()
await p2
await new Promise(r => setTimeout(r, 20))
expect(sockets[sockets.length - 1].sent).toHaveLength(0)
client.close()
})
it('replayed seqs advance watermarks but never regress them', async () => {
const client = makeClient()
const first = client.connect('ws://x')
let sock = sockets[sockets.length - 1]
sock.open()
await first
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'status.update', session_id: 's1', seq: 10 } })
client.invalidate('drop')
const second = client.connect('ws://x')
sock = sockets[sockets.length - 1]
sock.open()
await second
await vi.waitFor(() => {
expect(sock.lastRequest().method).toBe('session.events.since')
})
// Replay returns a STALE frame (seq 2 < watermark 10): watermark must hold.
const req = sock.lastRequest()
sock.serverFrame({
jsonrpc: '2.0',
id: req.id,
result: { events: [{ type: 'status.update', session_id: 's1', seq: 2 }], latest_seq: 10, truncated: false, count: 1 }
})
await Promise.resolve()
expect(client.getSeqWatermarks().s1).toBe(10)
client.close()
})
it('rejects envelope-shaped replay elements (the #94219 server-shape bug)', async () => {
const client = makeClient()
const seen: string[] = []
client.on('message.delta', () => seen.push('delta'))
const first = client.connect('ws://x')
let sock = sockets[sockets.length - 1]
sock.open()
await first
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 1 } })
expect(seen).toEqual(['delta']) // the pre-drop live frame
client.invalidate('drop')
const second = client.connect('ws://x')
sock = sockets[sockets.length - 1]
sock.open()
await second
await vi.waitFor(() => {
expect(sock.lastRequest().method).toBe('session.events.since')
})
const req = sock.lastRequest()
// Pre-fix servers returned FULL JSON-RPC envelopes. The client must not
// dispatch those blindly — and this documents why the server now sends
// bare event objects.
sock.serverFrame({
jsonrpc: '2.0',
id: req.id,
result: {
events: [{ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 2 } }],
latest_seq: 2,
truncated: false,
count: 1
}
})
await Promise.resolve()
// Envelope-shaped replay elements must add nothing beyond the live frame.
expect(seen).toEqual(['delta'])
client.close()
})
it('holds live frames racing the replay fetch — no double dispatch, no skipped gap', async () => {
const client = makeClient()
const seen: number[] = []
client.on('message.delta', e => seen.push((e as unknown as { seq: number }).seq))
const first = client.connect('ws://x')
let sock = sockets[sockets.length - 1]
sock.open()
await first
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 2 } })
expect(seen).toEqual([2]) // pre-drop live frame dispatches normally
client.invalidate('drop')
const second = client.connect('ws://x')
sock = sockets[sockets.length - 1]
sock.open()
await second
await vi.waitFor(() => {
expect(sock.lastRequest().method).toBe('session.events.since')
})
// LIVE frames 5 and 6 arrive while the replay (which carries 3,4,5) is
// still in flight. They must be parked, not dispatched ahead of the gap.
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 5 } })
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 6 } })
expect(seen).toEqual([2]) // still only the pre-drop frame — 5/6 parked
const req = sock.lastRequest()
sock.serverFrame({
jsonrpc: '2.0',
id: req.id,
result: {
events: [
{ type: 'message.delta', session_id: 's1', seq: 3 },
{ type: 'message.delta', session_id: 's1', seq: 4 },
{ type: 'message.delta', session_id: 's1', seq: 5 }
],
latest_seq: 5,
truncated: false,
count: 3
}
})
// In-order, exactly once: replayed 3,4,5 then the parked live 6 —
// the parked duplicate of 5 is seq-gated out.
await vi.waitFor(() => {
expect(seen).toEqual([2, 3, 4, 5, 6])
})
expect(client.getSeqWatermarks().s1).toBe(6)
client.close()
})
it('clears stale watermarks when the backend epoch changes (restart poisoning)', async () => {
const client = makeClient()
const first = client.connect('ws://x')
let sock = sockets[sockets.length - 1]
sock.open()
await first
// Learn epoch A and a high watermark.
sock.serverFrame({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready', payload: { replay_epoch: 'epoch-A' } }
})
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 97 } })
expect(client.getSeqWatermarks()).toEqual({ s1: 97 })
// Backend restarts: reconnect, replay under a NEW epoch returns nothing
// (fresh process, empty ring) — pre-fix the client kept watermark 97 and
// silently believed it missed nothing, forever.
client.invalidate('drop')
const second = client.connect('ws://x')
sock = sockets[sockets.length - 1]
sock.open()
await second
await vi.waitFor(() => {
expect(sock.lastRequest().method).toBe('session.events.since')
})
const req = sock.lastRequest()
sock.serverFrame({
jsonrpc: '2.0',
id: req.id,
result: { events: [], latest_seq: 0, truncated: false, count: 0, epoch: 'epoch-B' }
})
await vi.waitFor(() => {
expect(client.getSeqWatermarks()).toEqual({})
})
// New-epoch events build fresh watermarks from scratch.
sock.serverFrame({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: 's1', seq: 3 } })
expect(client.getSeqWatermarks()).toEqual({ s1: 3 })
client.close()
})
})
+755
View File
@@ -0,0 +1,755 @@
export type GatewayEventName =
| 'gateway.ready'
| 'session.info'
| 'session.usage'
| 'message.start'
| 'message.delta'
| 'message.interim'
| 'message.complete'
| 'thinking.delta'
| 'reasoning.delta'
| 'reasoning.available'
| 'status.update'
| 'tool.start'
| 'tool.progress'
| 'tool.complete'
| 'tool.generating'
| 'todo.updated'
| 'clarify.request'
| 'approval.request'
| 'sudo.request'
| 'secret.request'
| 'background.complete'
| 'error'
| 'skin.changed'
| (string & {})
export interface GatewayEvent<P = unknown> {
payload?: P
/** Renderer-side source tag added by the Desktop gateway registry. */
profile?: string
/** Registry connection whose socket delivered the event (renderer-side tag;
* absent for the local/legacy primary path). */
connectionId?: string
session_id?: string
type: GatewayEventName
}
export type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
export type GatewayRequestId = number | string
export interface JsonRpcErrorPayload {
code?: number
data?: unknown
message?: string
}
export interface JsonRpcFrame {
error?: JsonRpcErrorPayload
id?: GatewayRequestId | null
method?: string
params?: GatewayEvent
result?: unknown
}
/** JSON-RPC error with optional structured `data` from the gateway. */
export class JsonRpcGatewayError extends Error {
readonly code?: number
readonly data?: unknown
constructor(message: string, options?: { code?: number; data?: unknown }) {
super(message)
this.name = 'JsonRpcGatewayError'
this.code = options?.code
this.data = options?.data
}
}
export type WebSocketLike = WebSocket
type PendingCall = {
reject: (error: Error) => void
resolve: (value: unknown) => void
timer?: ReturnType<typeof setTimeout>
}
export interface GatewayClientOptions {
closedErrorMessage?: string
connectErrorMessage?: string
connectTimeoutMs?: number
createRequestId?: (nextId: number) => GatewayRequestId
heartbeatDeadlineMs?: number
heartbeatIntervalMs?: number
/** Return true to intercept the default closed-state transition. */
onSocketClose?: (event: CloseEvent) => boolean | void
requestIdPrefix?: string
requestTimeoutMs?: number
socketFactory?: (url: string) => WebSocketLike
notConnectedErrorMessage?: string
}
const ANY = '*'
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
// Replay fetch after reconnect: bounded so a wedged backend can't hold the
// guard open; generous enough for a 512-frame ring to drain.
const REPLAY_REQUEST_TIMEOUT_MS = 10_000
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
const DEFAULT_HEARTBEAT_DEADLINE_MS = 45_000
// A reconnect after sleep/wake must not hang forever in 'connecting' (which
// keeps the composer disabled and stuck on "Starting Hermes..."). If the open
// handshake doesn't land in this window, fail to 'error' so callers can retry.
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
export class JsonRpcGatewayClient {
private nextId = 0
private pending = new Map<GatewayRequestId, PendingCall>()
private socket: WebSocketLike | null = null
private state: ConnectionState = 'idle'
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private heartbeatSequence = 0
private lastInboundAt = 0
/** Last observed event seq per session_id — drives lossless reconnect replay. */
private lastSeenSeq = new Map<string, number>()
/** Set while a post-reconnect replay fetch is in flight (dedup guard). */
private replayInFlight = false
/**
* While a replay fetch is in flight, live seq'd frames for the sessions
* being replayed are parked here instead of dispatching immediately.
* Without this hold, a live frame racing the replay response is dispatched
* twice (once live, once when the replay returns the same seq) or, worse,
* advances the watermark so the gap events the replay carries get skipped.
*/
private replayHold: Map<string, GatewayEvent[]> | null = null
/**
* Server process identity for the replay contract (from gateway.ready /
* session.events.since). Seq counters are in-process on the backend, so a
* restart resets them while we still hold high watermarks — without this
* check events_since(sid, 97) returns [] + truncated=false forever and we
* silently believe nothing was missed.
*/
private replayEpoch: string | null = null
private readonly eventHandlers = new Map<string, Set<(event: GatewayEvent) => void>>()
private readonly stateHandlers = new Set<(state: ConnectionState) => void>()
private readonly options: Required<Omit<GatewayClientOptions, 'socketFactory'>> &
Pick<GatewayClientOptions, 'socketFactory'>
constructor(options: GatewayClientOptions = {}) {
this.options = {
closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed',
connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed',
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`),
heartbeatDeadlineMs: options.heartbeatDeadlineMs ?? DEFAULT_HEARTBEAT_DEADLINE_MS,
heartbeatIntervalMs: options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected',
onSocketClose: options.onSocketClose ?? (() => false),
requestIdPrefix: options.requestIdPrefix ?? 'r',
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
socketFactory: options.socketFactory
}
}
get connectionState(): ConnectionState {
return this.state
}
async connect(wsUrl: string): Promise<void> {
// Refuse garbage; WebSocket coerces non-strings into
// `ws://<origin>/[object%20Object]` (#68250 stale-emit boot loop).
const invalidUrl = () => {
const got = typeof wsUrl === 'string' ? JSON.stringify(wsUrl) : `type "${typeof wsUrl}"`
return new Error(`gateway connect() requires a ws:// or wss:// URL string, got ${got}`)
}
if (typeof wsUrl !== 'string') {
throw invalidUrl()
}
let url: URL
try {
url = new URL(wsUrl)
} catch {
throw invalidUrl()
}
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
throw invalidUrl()
}
if (this.socket?.readyState === WebSocket.OPEN || this.state === 'connecting') {
return
}
this.setState('connecting')
const socket = this.options.socketFactory?.(wsUrl) ?? new WebSocket(wsUrl)
this.socket = socket
this.stopHeartbeat()
socket.addEventListener('message', message => {
if (this.socket !== socket) {
return
}
this.lastInboundAt = Date.now()
this.handleMessage(message.data)
})
socket.addEventListener('close', event => {
if (this.socket !== socket) {
return
}
if (this.options.onSocketClose(event)) {
return
}
this.socket = null
this.stopHeartbeat()
this.setState('closed')
this.rejectAllPending(new Error(this.options.closedErrorMessage))
})
await new Promise<void>((resolve, reject) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (timer !== undefined) {
clearTimeout(timer)
}
socket.removeEventListener('open', onOpen)
socket.removeEventListener('error', onError)
}
const onOpen = () => {
if (settled || this.socket !== socket) {
return
}
settled = true
cleanup()
this.setState('open')
resolve()
// Lossless resume: drain events emitted while we were disconnected.
// Fire-and-forget so connect() latency is unaffected; only runs when
// we actually observed seq'd events before the drop.
void this.fetchReplay()
}
const onError = () => {
if (settled || this.socket !== socket) {
return
}
settled = true
cleanup()
this.setState('error')
reject(new Error(this.options.connectErrorMessage))
}
socket.addEventListener('open', onOpen, { once: true })
socket.addEventListener('error', onError, { once: true })
if (this.options.connectTimeoutMs > 0) {
timer = setTimeout(() => {
if (settled) {
return
}
settled = true
cleanup()
// Drop the half-open socket so the next connect() starts clean
// instead of short-circuiting on a zombie 'connecting' state.
if (this.socket === socket) {
try {
socket.close()
} catch {
// ignore
}
this.socket = null
this.setState('error')
}
reject(new Error(this.options.connectErrorMessage))
}, this.options.connectTimeoutMs)
}
})
}
close(): void {
const socket = this.socket
if (!socket) {
return
}
try {
socket.close()
} finally {
this.socket = null
this.stopHeartbeat()
this.setState('closed')
this.rejectAllPending(new Error(this.options.closedErrorMessage))
}
}
/**
* Invalidate the current socket generation after an ambiguous transport
* outcome. The outer connection owner decides whether/when to reconnect.
*/
invalidate(message = this.options.closedErrorMessage): void {
const socket = this.socket
if (!socket) {
return
}
this.invalidateSocket(socket, new Error(message))
}
on<P = unknown>(type: GatewayEventName, handler: (event: GatewayEvent<P>) => void): () => void {
let handlers = this.eventHandlers.get(type)
if (!handlers) {
handlers = new Set()
this.eventHandlers.set(type, handlers)
}
handlers.add(handler as (event: GatewayEvent) => void)
return () => handlers?.delete(handler as (event: GatewayEvent) => void)
}
onAny(handler: (event: GatewayEvent) => void): () => void {
return this.on(ANY as GatewayEventName, handler)
}
onEvent(handler: (event: GatewayEvent) => void): () => void {
return this.onAny(handler)
}
onState(handler: (state: ConnectionState) => void): () => void {
this.stateHandlers.add(handler)
handler(this.state)
return () => this.stateHandlers.delete(handler)
}
request<T>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = this.options.requestTimeoutMs,
signal?: AbortSignal
): Promise<T> {
const socket = this.socket
if (!socket || socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error(this.options.notConnectedErrorMessage))
}
if (signal?.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'))
}
const id = this.options.createRequestId(++this.nextId)
return new Promise<T>((resolve, reject) => {
let onAbort: (() => void) | undefined
const detach = () => {
if (onAbort && signal) {
signal.removeEventListener('abort', onAbort)
}
}
const pending: PendingCall = {
resolve: value => {
detach()
resolve(value as T)
},
reject: error => {
detach()
reject(error)
}
}
if (timeoutMs > 0) {
pending.timer = setTimeout(() => {
if (this.pending.delete(id)) {
detach()
// Include the configured timeout so a caller (or a user looking
// at an error toast) can tell whether the default 30s window
// fired or a per-call override — e.g. /compress opts into 120s.
const seconds = Math.round(timeoutMs / 1000)
reject(new Error(`request timed out after ${seconds}s: ${method}`))
}
}, timeoutMs)
}
// Abort drops the pending call immediately (no dangling resolver/timer);
// server-side cancellation is a separate cooperative RPC where it matters.
if (signal) {
onAbort = () => {
const call = this.pending.get(id)
if (call?.timer) {
clearTimeout(call.timer)
}
this.pending.delete(id)
detach()
reject(new DOMException('Aborted', 'AbortError'))
}
signal.addEventListener('abort', onAbort, { once: true })
}
this.pending.set(id, pending)
try {
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id,
method,
params
})
)
} catch (error) {
this.clearPending(id)
detach()
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
private handleMessage(raw: unknown): void {
const text = typeof raw === 'string' ? raw : String(raw)
let frame: JsonRpcFrame
try {
frame = JSON.parse(text) as JsonRpcFrame
} catch {
return
}
if (frame.id !== undefined && frame.id !== null) {
const call = this.pending.get(frame.id)
if (!call) {
return
}
this.clearPending(frame.id)
if (frame.error) {
call.reject(
new JsonRpcGatewayError(frame.error.message || 'Hermes RPC failed', {
code: typeof frame.error.code === 'number' ? frame.error.code : undefined,
data: frame.error.data
})
)
} else {
call.resolve(frame.result)
}
return
}
if (frame.method === 'event' && frame.params?.type) {
if (frame.params.type === 'gateway.ready') {
if (this.gatewayReadyAdvertisesHeartbeat(frame.params.payload)) {
const socket = this.socket
if (socket) {
this.startHeartbeat(socket)
}
}
const epoch = (frame.params.payload as { replay_epoch?: unknown } | undefined)?.replay_epoch
if (typeof epoch === 'string' && epoch) {
this.adoptReplayEpoch(epoch)
}
}
const sid = frame.params.session_id
const seqValue = (frame.params as { seq?: unknown }).seq
if (this.replayHold && sid && typeof seqValue === 'number' && this.replayHold.has(sid)) {
// Replay in flight for this session: park the frame; flushReplayHold
// dispatches it after the replayed gap, gated on seq.
this.replayHold.get(sid)?.push(frame.params)
return
}
this.recordSeq(frame.params)
this.dispatchEvent(frame.params)
}
}
/**
* Track each session's last observed event seq. Events without a seq
* (legacy backend, session-less globals) leave the map untouched.
*/
private recordSeq(event: GatewayEvent): void {
const sid = event.session_id
const seq = (event as { seq?: unknown }).seq
if (!sid || typeof seq !== 'number' || !Number.isFinite(seq)) {
return
}
const prev = this.lastSeenSeq.get(sid) ?? 0
if (seq > prev) {
this.lastSeenSeq.set(sid, seq)
}
}
/** Test/telemetry hook: current last-seen seq map snapshot. */
getSeqWatermarks(): Record<string, number> {
return Object.fromEntries(this.lastSeenSeq)
}
/**
* After a reconnect, ask the gateway to replay every event newer than our
* per-session watermarks. Replayed frames go through the SAME dispatchEvent
* path as live frames — dedupe happens naturally because recordSeq ignores
* non-increasing seqs and downstream stores key on event identity.
* Best-effort: failures are swallowed (the next reconnect retries).
*/
private async fetchReplay(): Promise<void> {
if (this.replayInFlight || this.lastSeenSeq.size === 0) {
return
}
this.replayInFlight = true
// Park live frames for the sessions we're about to replay so a frame
// racing the replay response can't dispatch ahead of (or duplicate) the
// gap events. Sessions without watermarks are unaffected.
const hold = new Map<string, GatewayEvent[]>()
for (const sid of this.lastSeenSeq.keys()) {
hold.set(sid, [])
}
this.replayHold = hold
try {
const entries = Object.entries(this.getSeqWatermarks())
// One RPC per known session keeps params flat; sessions are few (<20).
const results = await Promise.allSettled(
entries.map(([sid, lastSeen]) =>
this.request<{ events?: Array<{ type: string; session_id?: string; seq?: number; payload?: unknown }> }>(
'session.events.since',
{ session_id: sid, last_seen: lastSeen },
REPLAY_REQUEST_TIMEOUT_MS
)
)
)
for (const result of results) {
if (result.status !== 'fulfilled' || !Array.isArray(result.value?.events)) {
continue
}
const epoch = (result.value as { epoch?: unknown }).epoch
if (typeof epoch === 'string' && epoch && this.replayEpoch && epoch !== this.replayEpoch) {
// Backend restarted: its seq numbering reset, so our watermarks —
// and this replay window — are meaningless. Drop them and start
// fresh under the new epoch.
this.adoptReplayEpoch(epoch)
continue
}
if (typeof epoch === 'string' && epoch && !this.replayEpoch) {
this.replayEpoch = epoch
}
for (const event of result.value.events) {
if (!event?.type) {
continue
}
this.dispatchIfNewer(event as GatewayEvent)
}
}
} catch {
// Replay is an optimization over lossy-reconnect; never surface errors.
} finally {
this.flushReplayHold()
this.replayInFlight = false
}
}
/**
* Dispatch an event only when its seq advances the session watermark.
* Seq-less events always dispatch (no ordering contract to violate).
*/
private dispatchIfNewer(event: GatewayEvent): void {
const sid = event.session_id
const seq = (event as { seq?: unknown }).seq
if (sid && typeof seq === 'number' && Number.isFinite(seq)) {
const prev = this.lastSeenSeq.get(sid) ?? 0
if (seq <= prev) {
return
}
this.lastSeenSeq.set(sid, seq)
}
this.dispatchEvent(event)
}
/**
* Record the server's replay epoch; on change (backend restart) the old
* seq watermarks describe a numbering that no longer exists — clear them
* so the next reconnect doesn't silently believe it missed nothing.
*/
private adoptReplayEpoch(epoch: string): void {
if (this.replayEpoch === epoch) {
return
}
if (this.replayEpoch !== null) {
this.lastSeenSeq.clear()
}
this.replayEpoch = epoch
}
/** Release frames parked during a replay fetch, seq-gated against dupes. */
private flushReplayHold(): void {
const hold = this.replayHold
this.replayHold = null
if (!hold) {
return
}
for (const parked of hold.values()) {
for (const event of parked) {
this.dispatchIfNewer(event)
}
}
}
private gatewayReadyAdvertisesHeartbeat(payload: unknown): boolean {
return Boolean(payload && typeof payload === 'object' && (payload as { heartbeat?: unknown }).heartbeat === true)
}
private startHeartbeat(socket: WebSocketLike): void {
this.stopHeartbeat()
this.lastInboundAt = Date.now()
if (this.options.heartbeatIntervalMs <= 0 || this.options.heartbeatDeadlineMs <= 0) {
return
}
this.heartbeatTimer = setInterval(() => {
if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) {
return
}
if (Date.now() - this.lastInboundAt >= this.options.heartbeatDeadlineMs) {
this.invalidateSocket(socket, new Error('WebSocket heartbeat acknowledgement timed out'))
return
}
try {
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id: `heartbeat-${++this.heartbeatSequence}`,
method: 'gateway.ping',
params: {}
})
)
} catch (error) {
this.invalidateSocket(socket, error instanceof Error ? error : new Error(String(error)))
}
}, this.options.heartbeatIntervalMs)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private invalidateSocket(socket: WebSocketLike, error: Error): void {
if (this.socket !== socket) {
return
}
this.socket = null
this.stopHeartbeat()
try {
socket.close()
} catch {
// The generation was already invalidated; the reconnect owner can redial.
}
this.setState('closed')
this.rejectAllPending(error)
}
private clearPending(id: GatewayRequestId): void {
const call = this.pending.get(id)
if (call?.timer) {
clearTimeout(call.timer)
}
this.pending.delete(id)
}
private dispatchEvent(event: GatewayEvent): void {
for (const handler of this.eventHandlers.get(event.type) ?? []) {
handler(event)
}
for (const handler of this.eventHandlers.get(ANY) ?? []) {
handler(event)
}
}
private rejectAllPending(error: Error): void {
for (const [id, call] of this.pending) {
if (call.timer) {
clearTimeout(call.timer)
}
call.reject(error)
this.pending.delete(id)
}
}
private setState(state: ConnectionState): void {
if (this.state === state) {
return
}
this.state = state
for (const handler of this.stateHandlers) {
handler(state)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { skillInvocationText } from './skill-scaffold'
// Byte-identical to what agent/skill_commands.py emits — a desktop/TUI talking
// to an older gateway sees exactly these strings.
const BODY = 'SPIN UP A WORKTREE. Never edit the primary checkout.\n'.repeat(20)
const singleSkill = (instruction?: string) =>
[
'[IMPORTANT: The user has invoked the "work" skill, indicating they want you to follow its instructions.',
'The full skill content is loaded below.]',
'',
BODY,
'',
'[Skill directory: /Users/x/skills/work]',
...(instruction
? ['', `The user has provided the following instruction alongside the skill invocation: ${instruction}`]
: [])
].join('\n')
const bundle = (instruction?: string) =>
[
'[IMPORTANT: The user has invoked the "/clean /work" stacked skill bundle, loading 2 skills together.]',
'',
'Skills loaded: clean, work',
...(instruction ? ['', `User instruction: ${instruction}`] : []),
'',
'[Loaded as part of the stacked skill invocation "clean".]',
'',
BODY
].join('\n')
describe('skillInvocationText', () => {
it('renders a single-skill turn as the invocation, never the body', () => {
const projected = skillInvocationText(singleSkill('fix the title leak'))
expect(projected).toBe('/work fix the title leak')
expect(projected).not.toContain('WORKTREE')
})
it('renders a bare invocation as just the command', () => {
expect(skillInvocationText(singleSkill())).toBe('/work')
})
it('renders a bundle turn as the typed keys plus the instruction', () => {
const projected = skillInvocationText(bundle('ship it'))
expect(projected).toBe('/clean /work ship it')
expect(projected).not.toContain('WORKTREE')
})
it('collapses newlines in a multi-line instruction so the bubble stays one line', () => {
expect(skillInvocationText(singleSkill('fix the leak\n\nthen ship'))).toBe('/work fix the leak then ship')
})
it('leaves ordinary user prose alone', () => {
expect(skillInvocationText('just a normal message')).toBeNull()
expect(skillInvocationText('[IMPORTANT: read the docs]')).toBeNull()
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* A `/skill` invocation expands into a model-facing message that embeds the
* whole skill body. That payload is for the agent — the UI shows the
* invocation the user typed (`/work fix the leak`) and nothing else.
*
* The gateway already projects this (see `_skill_scaffold_projection` in
* tui_gateway/server.py) and ships the result as `display` on a dispatch and
* as the `text` of a `skill_invocation` history row. This module is the
* client-side twin so a desktop/TUI talking to an older gateway — or any
* future path that hands raw scaffolding to a bubble — still renders the
* invocation instead of the body.
*
* The markers below mirror `agent/skill_commands.py` byte for byte.
*/
const INVOCATION_PREFIX = '[IMPORTANT: The user has invoked the '
const SINGLE_MARKER = 'The full skill content is loaded below.]'
const SINGLE_INSTRUCTION = 'The user has provided the following instruction alongside the skill invocation: '
const RUNTIME_NOTE = '\n\n[Runtime note:'
const BUNDLE_MARKER = ' skill bundle,'
const BUNDLE_INSTRUCTION = '\nUser instruction: '
const BUNDLE_SKILL_BLOCK = '\n\n[Loaded as part of the '
// The skill name is the first quoted span of the activation note, for both the
// single-skill (`work`) and the bundle (`/clean /work`) header.
const NAME_RE = new RegExp(`^${INVOCATION_PREFIX.replace(/[[\]]/g, '\\$&')}"([^"]*)"`)
/** Text between `marker` and `end`, or '' when the marker is absent. */
function between(text: string, marker: string, end: string, fromEnd = false): string {
const index = fromEnd ? text.lastIndexOf(marker) : text.indexOf(marker)
if (index < 0) {
return ''
}
const tail = text.slice(index + marker.length)
const stop = tail.indexOf(end)
return (stop >= 0 ? tail.slice(0, stop) : tail).trim()
}
/**
* The invocation a scaffolded turn came from (`/work fix the leak`), or null
* when `text` is ordinary user prose that should render as written.
*/
export function skillInvocationText(text: string): null | string {
if (!text.startsWith(INVOCATION_PREFIX)) {
return null
}
const name = (NAME_RE.exec(text)?.[1] ?? '').trim()
if (!name) {
return null
}
// Bundle headers already carry their typed "/a /b" keys; a single skill is
// a bare name. The single-skill instruction trails the body (which may quote
// the marker), so match it from the end.
const label = name.startsWith('/') ? name : `/${name}`
const instruction = text.includes(BUNDLE_MARKER)
? between(text, BUNDLE_INSTRUCTION, BUNDLE_SKILL_BLOCK)
: text.includes(SINGLE_MARKER)
? between(text, SINGLE_INSTRUCTION, RUNTIME_NOTE, true)
: ''
return instruction ? `${label} ${instruction.replace(/\s+/g, ' ')}` : label
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Canonical Hermes skin — the theme SDK's cross-surface contract.
*
* A skin is authored once as YAML in `$HERMES_HOME/skins/<name>.yaml` (or a
* built-in), resolved by the Python skin engine (`hermes_cli/skin_engine.py`),
* and pushed to every surface over JSON-RPC (`gateway.ready`, `skin.changed`,
* `config.get skin`). This is the ONE shape every TypeScript surface consumes;
* each owns a resolver that normalizes it into its render model:
*
* • TUI → `fromSkin` → ansi-safe `Theme` (Ink)
* • Desktop → `skinToDesktopTheme` → CSS custom properties (Tailwind/shadcn)
* • CLI → `hermes_cli/skin_engine` → prompt_toolkit / Rich styles (Python)
*
* Tokens are terminal-first (the CLI is the oldest surface); GUIs derive their
* fuller palettes from the load-bearing few. Every field is optional — a resolver
* falls back to its own default for anything a skin omits.
*/
/** Canonical semantic color tokens a skin may set (the "enum" of the shape). */
export const SKIN_COLOR_TOKENS = [
// Base surface — GUIs + the TUI status bar derive their palette from this.
'background',
// Brand accent + primary.
'ui_accent',
'ui_primary',
'banner_accent',
'banner_title',
// Text.
'ui_text',
'banner_text',
'banner_dim',
// Structure.
'ui_border',
'banner_border',
// Semantic status.
'ui_ok',
'ui_warn',
'ui_error',
'ui_label',
// Element-specific (fall back to accent/muted when unset).
'ui_tool',
'ui_thinking',
'diff_added',
'diff_removed',
'diff_added_word',
'diff_removed_word',
'syntax_string',
'syntax_number',
'syntax_keyword',
'syntax_comment',
// CLI / TUI chrome.
'prompt',
'input_rule',
'response_border',
'shell_dollar',
'selection_bg',
'session_label',
'session_border',
'status_bar_bg',
'status_bar_text',
'status_bar_strong',
'status_bar_dim',
'status_bar_good',
'status_bar_warn',
'status_bar_bad',
'status_bar_critical',
'voice_status_bg',
'completion_menu_bg',
'completion_menu_current_bg',
'completion_menu_meta_bg',
'completion_menu_meta_current_bg'
] as const
export type SkinColorToken = (typeof SKIN_COLOR_TOKENS)[number]
/** Canonical branding/string tokens. */
export const SKIN_BRANDING_TOKENS = [
'agent_name',
'welcome',
'goodbye',
'response_label',
'prompt_symbol',
'help_header'
] as const
export type SkinBrandingToken = (typeof SKIN_BRANDING_TOKENS)[number]
/** Hex color per token. Open-ended so back-compat / niche keys still round-trip. */
export type SkinColors = Partial<Record<SkinColorToken, string>> & { [key: string]: string | undefined }
/** Branding strings per token. Open-ended for the same reason. */
export type SkinBranding = Partial<Record<SkinBrandingToken, string>> & { [key: string]: string | undefined }
/** The resolved skin payload (matches Python's `resolve_skin()`). */
export interface HermesSkin {
name?: string
description?: string
colors?: SkinColors
/** Hand-tuned palette overlay for dark terminals (light-authored skins).
* A resolver picks colors/light_colors/dark_colors by the terminal's
* detected polarity — see the TUI's `themeForSkin`. */
dark_colors?: SkinColors
/** Hand-tuned palette overlay for light terminals (dark-authored skins). */
light_colors?: SkinColors
branding?: SkinBranding
banner_logo?: string
banner_hero?: string
tool_prefix?: string
help_header?: string
}
+517
View File
@@ -0,0 +1,517 @@
/**
* Window translucency — the one place the main process and the renderer agree
* on what the setting means.
*
* One lever, 0100 (0 = off, the default). Two modes decide HOW the desktop
* shows through:
*
* - 'clear' — the main process maps the lever to native window opacity
* (`setOpacity`), so the whole window fades, text included. macOS + Windows;
* `setOpacity` is a no-op on Linux.
* - 'glass' — the window stays fully opaque at the native level and the
* renderer thins its page surfaces instead, letting a platform material
* read as a matte blur while text keeps full contrast. macOS rides
* `setVibrancy`; Windows 11 (22H2+) rides `setBackgroundMaterial`. Linux
* has no first-party desktop material, so glass is not offered there.
*
* The renderer owns the value and mirrors it to main over IPC; main persists it
* so a cold launch can apply it at window creation, before the renderer reports
* anything.
*/
export type TranslucencyMode = 'clear' | 'glass'
/**
* macOS vibrancy materials offered as glass "frost" levels, ordered sheer →
* heavy. macOS exposes no blur-radius knob (VibrancyOptions is only an
* animation duration), so the material IS the frost control: each maps to a
* different NSVisualEffectView material with its own luminance lift.
*
* Curated by pixel census on macOS 26 (one window, visualEffectState pinned
* to 'active', cycling all 14 materials over the same wallpaper): the 14
* collapse to 9 distinct looks (sidebar≡hud, window≡fullscreen-ui,
* tooltip≡content≡under-window≡under-page). These four are the ladder with
* the widest separations that stay distinct in BOTH appearances — dark lum
* 26/63/84/127, light lum 217/233/254/242. sidebar/hud sit 9 lum from
* under-window when focused and collapse INTO it when unfocused, which
* shipped as two indistinguishable picker options once — don't re-add them.
*/
export const GLASS_MATERIALS = ['under-window', 'popover', 'titlebar', 'header'] as const
export type GlassMaterial = (typeof GLASS_MATERIALS)[number]
export const DEFAULT_GLASS_MATERIAL: GlassMaterial = 'under-window'
/**
* Where the glass field lives. 'window' thins every field surface; 'sidebar'
* is the Finder shape — glass rail, opaque content column. The scope is a
* renderer concern (which surfaces thin); the main process only persists and
* echoes it.
*/
export const GLASS_SCOPES = ['window', 'sidebar'] as const
export type GlassScope = (typeof GLASS_SCOPES)[number]
export const DEFAULT_GLASS_SCOPE: GlassScope = 'window'
/**
* Electron `setBackgroundMaterial` values. `'auto'` is deliberately absent —
* it lets DWM pick, which would silently erase the frost choice.
*/
export const WINDOWS_BACKGROUND_MATERIALS = ['acrylic', 'tabbed', 'mica', 'none'] as const
export type WindowsBackgroundMaterial = (typeof WINDOWS_BACKGROUND_MATERIALS)[number]
/**
* Frost (sheer → heavy) → Windows 11 system backdrop. Acrylic is the live-blur
* transient material, closest to macOS under-window vibrancy; tabbed and mica
* sample the wallpaper and read more opaque.
*
* Three backdrops for four rungs, so the two heaviest both land on mica. The
* mapping stays total — a frost saved on a Mac still resolves — and the PICKER
* drops the duplicate instead (see `glassMaterialsFor`).
*/
const WINDOWS_MATERIAL_BY_FROST: Record<GlassMaterial, Exclude<WindowsBackgroundMaterial, 'none'>> = {
'under-window': 'acrylic',
popover: 'tabbed',
titlebar: 'mica',
header: 'mica'
}
/**
* The frost rungs Windows can render as DISTINCT looks: the first rung for each
* backdrop. Shipping two options that composite identically is the mistake the
* macOS census already corrected once (sidebar/hud); deriving the list from the
* mapping means a change there can never reintroduce a duplicate.
*/
const WINDOWS_GLASS_MATERIALS: readonly GlassMaterial[] = GLASS_MATERIALS.filter(
(material, index) =>
GLASS_MATERIALS.findIndex(rung => WINDOWS_MATERIAL_BY_FROST[rung] === WINDOWS_MATERIAL_BY_FROST[material]) === index
)
/**
* Windows 11 22H2 (build 22621) is the floor Electron documents for
* `setBackgroundMaterial`. Windows 11 still reports kernel 10.0; the build
* number is the discriminator. Fail closed on a missing/unparseable release.
*
* @see https://www.electronjs.org/docs/latest/api/browser-window#winsetbackgroundmaterialmaterial-windows
*/
export const WINDOWS_GLASS_MIN_BUILD = 22621
export interface TranslucencyState {
intensity: number
/**
* Glass only: native window opacity, on the same ramp Clear's lever uses.
* Defaults to 0 (no fade) because fading a glass window fades its text too —
* the very thing Glass exists to avoid. It is offered as a deliberate second
* lever, never as part of the tint.
*/
fade: number
mode: TranslucencyMode
material: GlassMaterial
scope: GlassScope
}
/**
* The half of the state that is scoped to the light/dark appearance.
*
* A tint that reads as a whisper over a dark palette is a milky sheet over a
* light one, so one shared number cannot serve both — the same setting has to
* mean a different amount in each appearance. `mode` stays global: clear vs
* glass is a choice about the window, not about the palette.
*/
export type TranslucencyValues = Omit<TranslucencyState, 'mode'>
export type Appearance = 'light' | 'dark'
/**
* Per-appearance defaults, per platform family. Glass ships ON: it is the
* better-looking half of the feature, and a lever that starts at zero is a
* feature nobody finds.
*
* The two platforms need different numbers because the lever means different
* things behind them. `intensity` is how much of the theme tint the renderer
* REMOVES (see `glassSurfaceKeep`), and what shows through underneath is a
* native material with its own weight:
*
* - macOS vibrancy is genuinely sheer, so the tint has to come most of the way
* off before the desktop reads at all. Light leans heavy — a bright desktop
* behind a bright window needs real thinning before the field separates —
* with a single point of fade so the window edge reads as glass rather than
* as paint. Dark takes far less: a dark field already separates, and the
* tint that flatters light would smother it.
* - Windows acrylic composites its OWN tint in DWM before the page is drawn,
* so the renderer's tint stacks on top of a backdrop that is already doing
* the work. The same numbers that read as frost on a Mac read as a washed
* sheet here; these stay low and let DWM carry it. Fade stays at zero —
* `setOpacity` over a system backdrop dims the composited result rather than
* deepening it.
*
* Both sit on the frost each platform renders best: 'header' and 'titlebar'
* are macOS-only rungs (on Windows they collapse onto mica — see
* `glassMaterialsFor`), while 'under-window' is the acrylic rung, the live
* blur closest to what macOS calls under-window.
*/
const DEFAULT_VALUES: Record<'mac' | 'windows', Record<Appearance, TranslucencyValues>> = {
mac: {
light: { intensity: 66, fade: 1, material: 'header', scope: 'window' },
dark: { intensity: 22, fade: 0, material: 'titlebar', scope: 'window' }
},
windows: {
light: { intensity: 20, fade: 0, material: 'under-window', scope: 'window' },
dark: { intensity: 5, fade: 0, material: 'under-window', scope: 'window' }
}
}
/**
* The untouched values for an appearance on this platform. Linux never reaches
* here — translucency is unsupported there, so nothing resolves.
*/
export function defaultTranslucencyValues(appearance: Appearance, isWindows: boolean): TranslucencyValues {
return DEFAULT_VALUES[isWindows ? 'windows' : 'mac'][appearance]
}
/**
* The renderer's book of translucency settings.
*
* `base` is the shared rung: a value the user set before appearances were
* split (a migrated v1 state), or one they have never touched. An appearance
* slot only carries the keys edited WHILE that appearance was painted, so
* changing the tint in light mode leaves dark's alone and an untouched dark
* still inherits whatever base says. That is the ladder — appearance over base
* over default, per key, so \"unset\" keeps carrying over.
*
* The book is renderer-owned. The main process is handed the RESOLVED state
* (see `resolveTranslucency`) because a window's backing, vibrancy and opacity
* only ever concern the appearance actually on screen.
*/
export interface TranslucencyBook {
mode: TranslucencyMode
base: Partial<TranslucencyValues>
light: Partial<TranslucencyValues>
dark: Partial<TranslucencyValues>
}
export const TRANSLUCENCY_MIN = 0
export const TRANSLUCENCY_MAX = 100
/** Renderer slider granularity. Main accepts any integer in range. */
export const TRANSLUCENCY_STEP = 1
/** Most see-through clear setting — floored so it stays usable, not invisible. */
export const TRANSLUCENCY_OPACITY_FLOOR = 0.3
/**
* Exponent for the clear intensity → opacity ramp. 1 is a linear ramp, which
* spends the whole readable band (opacity ≳ 0.95) in the first few percent of
* the lever. 2 holds that band across roughly the first third while leaving
* both endpoints bit-identical to the linear ramp.
*/
export const TRANSLUCENCY_CURVE = 2
export function clampIntensity(value: unknown): number {
const n = Math.round(Number(value))
return Number.isFinite(n) ? Math.min(TRANSLUCENCY_MAX, Math.max(TRANSLUCENCY_MIN, n)) : TRANSLUCENCY_MIN
}
/**
* Whether this OS can do ANY translucency. Clear rides `setOpacity`, which
* Electron documents as doing nothing on Linux, and glass needs a native
* material Linux does not have — so the whole setting is dead there and the
* row should not be shown at all.
*/
export function translucencySupportedOn(platform: string): boolean {
return platform === 'darwin' || platform === 'win32'
}
/**
* Whether this OS can back glass with a first-party Electron material.
* macOS: `setVibrancy`. Windows 11 22H2+: `setBackgroundMaterial`. Linux and
* older Windows: no.
*/
export function glassSupportedOn(platform: string, release = ''): boolean {
if (platform === 'darwin') {
return true
}
if (platform !== 'win32') {
return false
}
const build = Number.parseInt(release.split('.')[2] ?? '', 10)
return Number.isFinite(build) && build >= WINDOWS_GLASS_MIN_BUILD
}
/**
* Glass needs a native window material, so unsupported platforms stay on
* 'clear'.
*
* With no mode recorded, a glass-capable OS gets glass — it is the
* better-looking half of the feature and the one worth finding, and
* pre-selecting it costs a fresh profile nothing because the intensity still
* starts at 0 (the whole feature is off until the user raises the lever).
* `legacyIntensity` is the escape hatch: a profile that already carries a
* NON-ZERO intensity but no mode predates this setting and has been rendering
* as clear all along, so it keeps rendering as clear. Flipping a window
* someone already tuned is the one thing a default must not do.
*/
export function normalizeMode(value: unknown, glassSupported: boolean, legacyIntensity = 0): TranslucencyMode {
if (!glassSupported) {
return 'clear'
}
if (value === 'glass' || value === 'clear') {
return value
}
return legacyIntensity > 0 ? 'clear' : 'glass'
}
/** Unknown or unsupported values fall back to the default material. */
export function normalizeMaterial(value: unknown): GlassMaterial {
return GLASS_MATERIALS.includes(value as GlassMaterial) ? (value as GlassMaterial) : DEFAULT_GLASS_MATERIAL
}
/** Unknown or unsupported values fall back to whole-window glass. */
export function normalizeScope(value: unknown): GlassScope {
return GLASS_SCOPES.includes(value as GlassScope) ? (value as GlassScope) : DEFAULT_GLASS_SCOPE
}
/** Parse a persisted translucency.json / IPC payload into a safe state. */
export function normalizeState(payload: unknown, glassSupported: boolean): TranslucencyState {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const intensity = clampIntensity(record.intensity)
return {
intensity,
fade: clampIntensity(record.fade),
mode: normalizeMode(record.mode, glassSupported, intensity),
material: normalizeMaterial(record.material),
scope: normalizeScope(record.scope)
}
}
/**
* The resolved state a surface should assume before anyone has said otherwise.
*
* Main needs this at window CREATION on a first launch: the renderer has not
* reported yet, and a window created with the opaque backing cannot reliably
* be swapped to glass moments later (see `windowBackingOptions`). Guessing the
* appearance from `nativeTheme` is close enough — the renderer's first resolved
* send corrects any mismatch while the window is still young, and every launch
* after the first reads the persisted state instead.
*/
export function defaultTranslucencyState(
appearance: Appearance,
glassSupported: boolean,
isWindows: boolean
): TranslucencyState {
return {
...defaultTranslucencyValues(appearance, isWindows),
mode: normalizeMode(undefined, glassSupported)
}
}
/** Keep only the value keys actually present, each normalized. Unknown keys drop. */
function normalizeValues(payload: unknown): Partial<TranslucencyValues> {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const out: Partial<TranslucencyValues> = {}
if (record.intensity !== undefined) {
out.intensity = clampIntensity(record.intensity)
}
if (record.fade !== undefined) {
out.fade = clampIntensity(record.fade)
}
if (record.material !== undefined) {
out.material = normalizeMaterial(record.material)
}
if (record.scope !== undefined) {
out.scope = normalizeScope(record.scope)
}
return out
}
/**
* Parse a persisted book, or migrate a flat v1 state into one.
*
* A v1 payload is a window someone already tuned, so its values land in `base`
* — every appearance inherits exactly what was on screen before the upgrade,
* and the new per-appearance defaults apply only where nothing was ever set.
* The legacy clear rule rides along: a non-zero v1 intensity with no mode was
* rendering as clear and keeps doing so.
*/
export function normalizeBook(payload: unknown, glassSupported: boolean): TranslucencyBook {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const migrating = record.base === undefined && record.light === undefined && record.dark === undefined
const base = normalizeValues(migrating ? record : record.base)
const legacyIntensity = migrating ? clampIntensity(record.intensity) : 0
return {
mode: normalizeMode(record.mode, glassSupported, legacyIntensity),
base,
light: normalizeValues(migrating ? null : record.light),
dark: normalizeValues(migrating ? null : record.dark)
}
}
/**
* Flatten the book for one appearance: appearance slot → base → default.
*
* This is the only thing outside the renderer's settings surface that should
* ever be handed around — main, the CSS field surfaces, and every consumer of
* `$translucency` all want the resolved answer for what is painted right now.
*/
export function resolveTranslucency(
book: TranslucencyBook,
appearance: Appearance,
isWindows: boolean
): TranslucencyState {
const fallback = defaultTranslucencyValues(appearance, isWindows)
const slot = book[appearance]
return {
mode: book.mode,
intensity: slot.intensity ?? book.base.intensity ?? fallback.intensity,
fade: slot.fade ?? book.base.fade ?? fallback.fade,
material: slot.material ?? book.base.material ?? fallback.material,
scope: slot.scope ?? book.base.scope ?? fallback.scope
}
}
/**
* Record an edit against the appearance being painted.
*
* The edit is written to the appearance slot rather than to base, so tuning
* light mode is scoped to light mode. Base is left intact as the inheritance
* rung for whichever appearance has not been touched.
*/
export function setTranslucencyValues(
book: TranslucencyBook,
appearance: Appearance,
patch: Partial<TranslucencyValues>
): TranslucencyBook {
return { ...book, [appearance]: { ...book[appearance], ...normalizeValues(patch) } }
}
/** Lever percent → native window opacity, floored so it stays usable. */
function opacityRamp(lever: number): number {
const ratio = clampIntensity(lever) / TRANSLUCENCY_MAX
return 1 - (1 - TRANSLUCENCY_OPACITY_FLOOR) * Math.pow(ratio, TRANSLUCENCY_CURVE)
}
/**
* Native window opacity for a state.
*
* Under Clear the lever IS the opacity. Under Glass the lever paints the tint
* and only the separate `fade` reaches the window, so a glass window stays at
* 1 until the user opts into fading it — which is what keeps a tint drag from
* touching anything native.
*
* Fade is gated on glass being ACTIVE, not merely selected. The light default
* carries a single point of it so the window edge reads as glass rather than
* as paint, and without this gate that point would follow someone who had
* turned the tint to zero — leaving a window that asked to be opaque sitting
* at 0.9999. Off has to mean off.
*/
export function windowOpacityFor(state: TranslucencyState): number {
if (state.mode !== 'glass') {
return opacityRamp(state.intensity)
}
return opacityRamp(glassActive(state) ? state.fade : 0)
}
/**
* Whether glass is visually active. Both processes branch on this: main to
* decide a window's backing, the renderer to decide whether to thin surfaces.
*/
export function glassActive({ intensity, mode }: TranslucencyState): boolean {
return mode === 'glass' && intensity > 0
}
/**
* Percent of the surface tint the renderer KEEPS at a given intensity. Linear
* to zero: at 100 the tint is fully gone — bare platform glass — so the slider
* spans the whole range from opaque theme to untinted blur. Text and cards
* keep their own opaque tokens for contrast; only the field surfaces thin.
*/
export function glassSurfaceKeep(intensity: number): number {
return TRANSLUCENCY_MAX - clampIntensity(intensity)
}
/**
* The vibrancy material a chat window should carry. 'sidebar' is the
* long-standing default the titlebar band was designed against; glass mode
* swaps the whole window onto the user's chosen material (setVibrancy is
* cheap and animatable at runtime, unlike the backing).
*/
export function vibrancyFor(state: TranslucencyState): GlassMaterial | 'sidebar' {
return glassActive(state) ? state.material : 'sidebar'
}
/**
* The Windows 11 system backdrop a chat window should carry. 'none' while
* glass is off so DWM does not keep drawing mica/acrylic under the opaque
* themed backing.
*/
export function backgroundMaterialFor(state: TranslucencyState): WindowsBackgroundMaterial {
return glassActive(state) ? WINDOWS_MATERIAL_BY_FROST[state.material] : 'none'
}
/** The frost rungs to offer on this platform. */
export function glassMaterialsFor(isWindows: boolean): readonly GlassMaterial[] {
return isWindows ? WINDOWS_GLASS_MATERIALS : GLASS_MATERIALS
}
/**
* The native frost a HUD-style transparent window should carry.
*
* Two gates, because the HUD's frost answers to more than the setting. The
* material is the WINDOW's — nothing on the page can clip it — so it is only
* ever right while the band actually covers the window below the bar;
* `showing` is the renderer's answer to that (see `useHudGlass`). The setting
* is the other half: Glass off, or the tint at zero, means no frost at all.
*
* The off answer is `null` rather than a resting material, which is the one
* way this differs from `vibrancyFor`. A chat window is opaque and keeps
* 'sidebar' under its titlebar band whatever the setting says; a transparent
* window has no opaque page to hide an unwanted material behind, so off has
* to mean off or the frost is a grey slab hanging over someone else's app.
*/
export function hudFrostFor(
state: TranslucencyState,
showing: boolean
): { backgroundMaterial: WindowsBackgroundMaterial; vibrancy: GlassMaterial | null } {
const active = showing && glassActive(state)
return {
vibrancy: active ? state.material : null,
backgroundMaterial: active ? backgroundMaterialFor(state) : 'none'
}
}
/**
* The rung the picker highlights. A frost with no rung of its own here — a
* Mac's 'header' read on Windows — folds onto the rung that renders the same
* backdrop, so the picker shows a truthful selection without rewriting the
* value the user saved on their other machine.
*/
export function glassMaterialForPicker(material: GlassMaterial, isWindows: boolean): GlassMaterial {
if (!isWindows) {
return material
}
return (
WINDOWS_GLASS_MATERIALS.find(rung => WINDOWS_MATERIAL_BY_FROST[rung] === WINDOWS_MATERIAL_BY_FROST[material]) ??
DEFAULT_GLASS_MATERIAL
)
}
+151
View File
@@ -0,0 +1,151 @@
export type GatewayAuthMode = 'oauth' | 'token' | (string & {})
export interface GatewayWsConnection {
authMode?: GatewayAuthMode | null
profile?: null | string
wsUrl: string
}
export interface ResolveGatewayWsUrlDeps {
/**
* Returns a fresh WebSocket URL for the selected backend/profile.
* OAuth-gated gateways use single-use tickets, so callers should mint
* immediately before opening the socket.
*/
getGatewayWsUrl?: (profile?: null | string) => Promise<GatewayWsUrlResult>
}
export type GatewayWsUrlResult =
| string
| { ok: true; wsUrl: string }
| { error: string; needsOauthLogin?: boolean; ok: false }
export class GatewayReauthRequiredError extends Error {
readonly needsOauthLogin = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'GatewayReauthRequiredError'
}
}
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
return (
error instanceof GatewayReauthRequiredError ||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
)
}
export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: GatewayWsConnection): Promise<string> {
const mint = deps.getGatewayWsUrl
const profile = conn.profile ?? null
if (conn.authMode === 'oauth') {
if (!mint) {
throw new Error('This Desktop build cannot refresh OAuth WebSocket tickets. Update Hermes Desktop and try again.')
}
try {
const result = await mint(profile)
if (typeof result === 'string') {
return result
}
if (result.ok) {
return result.wsUrl
}
if (result.needsOauthLogin) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.',
{ cause: new Error(result.error) }
)
}
throw new Error(result.error || 'Could not refresh the remote gateway WebSocket ticket.')
} catch (error) {
if (isGatewayReauthRequired(error)) {
throw error instanceof GatewayReauthRequiredError
? error
: new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.',
{ cause: error }
)
}
throw error
}
}
if (mint) {
const fresh = await mint(profile).catch(() => null)
if (typeof fresh === 'string') {
return fresh
}
if (fresh?.ok) {
return fresh.wsUrl
}
}
return conn.wsUrl
}
export type WebSocketAuthParam = readonly [name: string, value: string]
export interface HermesWebSocketUrlOptions {
/** Dashboard or gateway-relative endpoint path, e.g. "/api/ws". */
path: string
/** Optional URL prefix when the backend is reverse-proxied below a subpath. */
basePath?: string
/** Query auth pair, usually ["token", value] or ["ticket", value]. */
authParam?: WebSocketAuthParam
/** Extra query params merged before auth. */
params?: Record<string, string>
/** Browser protocol string such as "https:"; defaults to window.location.protocol. */
protocol?: string
/** Host with optional port; defaults to window.location.host. */
host?: string
}
function readWindowLocation(): { host: string; protocol: string } {
if (typeof window === 'undefined') {
return { host: '', protocol: 'http:' }
}
return { host: window.location.host, protocol: window.location.protocol }
}
function normalizeBasePath(basePath: string | undefined): string {
if (!basePath) {
return ''
}
const withLead = basePath.startsWith('/') ? basePath : `/${basePath}`
return withLead.replace(/\/+$/, '')
}
function normalizeEndpointPath(path: string): string {
return path.startsWith('/') ? path : `/${path}`
}
export function buildHermesWebSocketUrl(options: HermesWebSocketUrlOptions): string {
const loc = readWindowLocation()
const protocol = options.protocol ?? loc.protocol
const host = options.host ?? loc.host
const wsScheme = protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:'
const qs = new URLSearchParams(options.params ?? {})
if (options.authParam) {
const [name, value] = options.authParam
qs.set(name, value)
}
const query = qs.toString()
const suffix = query ? `?${query}` : ''
return `${wsScheme}//${host}${normalizeBasePath(options.basePath)}${normalizeEndpointPath(options.path)}${suffix}`
}