| 1 | import type { BalanceInfo, ContextInfo, CostQuote, JobView, WireUsage } from "./types"; |
| 2 | import type { State } from "./useController"; |
| 3 | import { app } from "./bridge"; |
| 4 | |
| 5 | type RemoteTelemetryStatus = { |
| 6 | used?: unknown; |
| 7 | window?: unknown; |
| 8 | cacheHit?: unknown; |
| 9 | cacheMiss?: unknown; |
| 10 | lastUsage?: unknown; |
| 11 | balance?: unknown; |
| 12 | sessionCostQuote?: unknown; |
| 13 | jobs?: unknown; |
| 14 | }; |
| 15 | |
| 16 | async function retryRemote<T>(load: () => Promise<T>, maxAttempts: number, cancelled: () => boolean): Promise<T | undefined> { |
| 17 | for (let attempt = 0; attempt < maxAttempts && !cancelled(); attempt++) { |
| 18 | try { |
| 19 | return await load(); |
| 20 | } catch (error) { |
| 21 | if (attempt + 1 === maxAttempts) throw error; |
| 22 | await new Promise<void>((resolve) => setTimeout(resolve, 500)); |
| 23 | } |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | export function loadRemoteStatusSnapshot(tabId: string, maxAttempts: number, cancelled: () => boolean, valid: (status: unknown) => boolean, metadataOnly = false) { |
| 28 | return retryRemote(async () => { |
| 29 | const snapshot = metadataOnly && app.RemoteTabMetadata ? await app.RemoteTabMetadata(tabId) : await app.RemoteTabSnapshot(tabId); |
| 30 | const status = snapshot.status ?? await app.RemoteTabStatus(tabId); |
| 31 | if (!valid(status)) throw new Error("remote status is incomplete"); |
| 32 | return [snapshot, status] as const; |
| 33 | }, maxAttempts, cancelled); |
| 34 | } |
| 35 | |
| 36 | function finiteNumber(value: unknown): number | undefined { |
| 37 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; |
| 38 | } |
| 39 | |
| 40 | function remoteUsage(value: unknown): WireUsage | undefined { |
| 41 | if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; |
| 42 | const raw = value as Record<string, unknown>; |
| 43 | const number = (camel: string, legacy: string) => finiteNumber(raw[camel] ?? raw[legacy]) ?? 0; |
| 44 | return { |
| 45 | promptTokens: number("promptTokens", "PromptTokens"), |
| 46 | completionTokens: number("completionTokens", "CompletionTokens"), |
| 47 | totalTokens: number("totalTokens", "TotalTokens"), |
| 48 | cacheHitTokens: number("cacheHitTokens", "CacheHitTokens"), |
| 49 | cacheMissTokens: number("cacheMissTokens", "CacheMissTokens"), |
| 50 | reasoningTokens: number("reasoningTokens", "ReasoningTokens"), |
| 51 | estimated: raw.estimated === true || raw.Estimated === true, |
| 52 | sessionCacheHitTokens: number("sessionCacheHitTokens", "SessionCacheHitTokens"), |
| 53 | sessionCacheMissTokens: number("sessionCacheMissTokens", "SessionCacheMissTokens"), |
| 54 | contextPromptTokens: number("contextPromptTokens", "ContextPromptTokens") || undefined, |
| 55 | contextCompletionTokens: number("contextCompletionTokens", "ContextCompletionTokens") || undefined, |
| 56 | contextReasoningTokens: number("contextReasoningTokens", "ContextReasoningTokens") || undefined, |
| 57 | contextCacheHitTokens: number("contextCacheHitTokens", "ContextCacheHitTokens") || undefined, |
| 58 | contextCacheMissTokens: number("contextCacheMissTokens", "ContextCacheMissTokens") || undefined, |
| 59 | }; |
| 60 | } |
| 61 | |
| 62 | function remoteBalance(value: unknown): BalanceInfo | undefined { |
| 63 | if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; |
| 64 | const raw = value as Record<string, unknown>; |
| 65 | return { |
| 66 | available: raw.available === true, |
| 67 | display: typeof raw.display === "string" ? raw.display : "", |
| 68 | detail: typeof raw.detail === "string" ? raw.detail : undefined, |
| 69 | complete: raw.complete === true, |
| 70 | primaryCurrency: typeof raw.primaryCurrency === "string" ? raw.primaryCurrency : undefined, |
| 71 | costDisplayCurrency: typeof raw.costDisplayCurrency === "string" ? raw.costDisplayCurrency : undefined, |
| 72 | multiCurrency: raw.multiCurrency === true, |
| 73 | }; |
| 74 | } |
| 75 | |
| 76 | function remoteJobs(value: unknown): JobView[] { |
| 77 | if (!Array.isArray(value)) return []; |
| 78 | return value.flatMap((entry) => { |
| 79 | if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; |
| 80 | const raw = entry as Record<string, unknown>; |
| 81 | if (typeof raw.id !== "string" || raw.id.trim() === "") return []; |
| 82 | return [{ |
| 83 | id: raw.id, |
| 84 | kind: typeof raw.kind === "string" ? raw.kind : "task", |
| 85 | label: typeof raw.label === "string" ? raw.label : raw.id, |
| 86 | status: typeof raw.status === "string" ? raw.status : "running", |
| 87 | startedAt: finiteNumber(raw.startedAt) ?? 0, |
| 88 | }]; |
| 89 | }); |
| 90 | } |
| 91 | |
| 92 | export function hydrateRemoteTelemetry(state: State, status: unknown): State { |
| 93 | const raw = (status ?? {}) as RemoteTelemetryStatus; |
| 94 | const lastUsage = remoteUsage(raw.lastUsage); |
| 95 | const quote = raw.sessionCostQuote && typeof raw.sessionCostQuote === "object" && !Array.isArray(raw.sessionCostQuote) |
| 96 | ? raw.sessionCostQuote as CostQuote |
| 97 | : undefined; |
| 98 | const selectedAmount = quote?.selected ? Number.parseFloat(quote.selected.amount) : 0; |
| 99 | const cacheHit = finiteNumber(raw.cacheHit) ?? lastUsage?.sessionCacheHitTokens ?? 0; |
| 100 | const cacheMiss = finiteNumber(raw.cacheMiss) ?? lastUsage?.sessionCacheMissTokens ?? 0; |
| 101 | const context: ContextInfo = { |
| 102 | used: Math.max(0, finiteNumber(raw.used) ?? 0), |
| 103 | window: Math.max(0, finiteNumber(raw.window) ?? 0), |
| 104 | sessionTokens: Math.max(0, cacheHit + cacheMiss, lastUsage?.totalTokens ?? 0), |
| 105 | cacheHitTokens: Math.max(0, cacheHit), |
| 106 | cacheMissTokens: Math.max(0, cacheMiss), |
| 107 | sessionCost: Number.isFinite(selectedAmount) ? Math.max(0, selectedAmount) : 0, |
| 108 | sessionCurrency: quote?.selected?.currency ?? "", |
| 109 | sessionCostComplete: quote?.costComplete === true, |
| 110 | sessionCostQuote: quote, |
| 111 | estimated: quote?.estimated === true || lastUsage?.estimated === true, |
| 112 | }; |
| 113 | return { |
| 114 | ...state, |
| 115 | context, |
| 116 | balance: remoteBalance(raw.balance), |
| 117 | jobs: remoteJobs(raw.jobs), |
| 118 | usage: lastUsage, |
| 119 | sessionTokens: context.sessionTokens ?? 0, |
| 120 | sessionCost: context.sessionCost ?? 0, |
| 121 | sessionCurrency: context.sessionCurrency ?? "", |
| 122 | lastTurnOutputTokens: lastUsage ? Math.max(0, lastUsage.completionTokens) : 0, |
| 123 | lastTurnOutputEstimated: lastUsage?.estimated === true, |
| 124 | }; |
| 125 | } |
| 126 |