Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Window translucency — the one place the main process and the renderer agree
|
||||
* on what the setting means.
|
||||
*
|
||||
* One lever, 0–100 (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
|
||||
)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user