From a7345ea691a3846f532d129b78fd798fb47e8a4f Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Sat, 15 Aug 2026 18:40:06 +0500 Subject: [PATCH 1/4] feat(usage): add single-free-account fair-use policy with unit tests --- src/config.ts | 9 ++ src/test/freeAccountPolicy.test.ts | 129 ++++++++++++++++++++++++ src/usage/freeAccountPolicy.ts | 155 +++++++++++++++++++++++++++++ src/usage/freeAccountStorage.ts | 37 +++++++ 4 files changed, 330 insertions(+) create mode 100644 src/test/freeAccountPolicy.test.ts create mode 100644 src/usage/freeAccountPolicy.ts create mode 100644 src/usage/freeAccountStorage.ts diff --git a/src/config.ts b/src/config.ts index bd31b70..a248b6e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -196,6 +196,15 @@ export const GO_MAX_LOG_ENTRIES = 2000; export const GO_SESSION_IDLE_MS = 2 * 60 * 60 * 1000; export const GO_MAX_SESSIONS = 50; +// ─── Single-free-account fair-use policy (1 free account per install) ──────── + +/** globalState key listing fingerprints currently treated as free accounts. */ +export const FREE_ACCOUNTS_STATE_KEY = "opencodego.freeAccounts.v1"; +/** globalState key mapping a fingerprint to the last time it was confirmed paid (ms). */ +export const PAID_ACCOUNTS_STATE_KEY = "opencodego.paidAccounts.v1"; +/** How long a "confirmed paid" status stays valid before the account is treated as free again. */ +export const PAID_CONFIRMATION_TTL_MS = 24 * 60 * 60 * 1000; + // ─── Usage display options ──────────────────────────────────────────────────── /** diff --git a/src/test/freeAccountPolicy.test.ts b/src/test/freeAccountPolicy.test.ts new file mode 100644 index 0000000..e500c58 --- /dev/null +++ b/src/test/freeAccountPolicy.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { PAID_CONFIRMATION_TTL_MS } from "../config.js"; +import { + countFreeAccounts, + emptyFreeAccountPolicy, + fromStored, + isPaid, + markFreeUsage, + markPaid, + removeAccount, + shouldBlockFreeUsage, + toStored, + unmarkPaid, +} from "../usage/freeAccountPolicy.js"; + +const NOW = 1_700_000_000_000; + +describe("freeAccountPolicy", () => { + it("always allows a single free account", () => { + let p = emptyFreeAccountPolicy(); + p = markFreeUsage(p, "A", NOW); + assert.equal(countFreeAccounts(p, NOW), 1); + assert.equal(shouldBlockFreeUsage(p, NOW), false); + }); + + it("blocks free usage once a second free account is seen", () => { + let p = emptyFreeAccountPolicy(); + p = markFreeUsage(p, "A", NOW); + p = markFreeUsage(p, "B", NOW); + assert.equal(countFreeAccounts(p, NOW), 2); + assert.equal(shouldBlockFreeUsage(p, NOW), true); + }); + + it("does not double-count the same fingerprint", () => { + let p = emptyFreeAccountPolicy(); + p = markFreeUsage(p, "A", NOW); + p = markFreeUsage(p, "A", NOW); + assert.equal(countFreeAccounts(p, NOW), 1); + assert.equal(shouldBlockFreeUsage(p, NOW), false); + }); + + it("exempts confirmed paid accounts from the free limit", () => { + let p = emptyFreeAccountPolicy(); + p = markFreeUsage(p, "A", NOW); // free account A + p = markPaid(p, "B", NOW); // paid account B + p = markFreeUsage(p, "B", NOW); // B uses a free model but is paid → not counted + assert.equal(countFreeAccounts(p, NOW), 1); + assert.equal(shouldBlockFreeUsage(p, NOW), false); + }); + + it("allows multiple active paid accounts even when they use free models", () => { + let p = emptyFreeAccountPolicy(); + p = markPaid(p, "P1", NOW); + p = markPaid(p, "P2", NOW); + p = markFreeUsage(p, "P1", NOW); + p = markFreeUsage(p, "P2", NOW); + assert.equal(countFreeAccounts(p, NOW), 0); + assert.equal(shouldBlockFreeUsage(p, NOW), false); + }); + + it("re-classifies a lapsed paid account as free after the confirmation TTL", () => { + let p = emptyFreeAccountPolicy(); + p = markPaid(p, "P", NOW); // paid while the subscription was active + const later = NOW + PAID_CONFIRMATION_TTL_MS + 1; // sub lapsed + assert.equal(isPaid(p, "P", later), false); + p = markFreeUsage(p, "P", later); // now uses a free model + assert.equal(countFreeAccounts(p, later), 1); + }); + + it("blocks two lapsed paid accounts once both are used as free", () => { + let p = emptyFreeAccountPolicy(); + p = markPaid(p, "P1", NOW); + p = markPaid(p, "P2", NOW); + const later = NOW + PAID_CONFIRMATION_TTL_MS + 1; + p = markFreeUsage(p, "P1", later); + p = markFreeUsage(p, "P2", later); + assert.equal(countFreeAccounts(p, later), 2); + assert.equal(shouldBlockFreeUsage(p, later), true); + }); + + it("renews paid status on fresh paid confirmation (subscription renewed)", () => { + let p = emptyFreeAccountPolicy(); + p = markPaid(p, "P", NOW); + const later = NOW + PAID_CONFIRMATION_TTL_MS + 1; + p = markPaid(p, "P", later); // renewed + p = markFreeUsage(p, "P", later); + assert.equal(isPaid(p, "P", later), true); + assert.equal(countFreeAccounts(p, later), 0); + }); + + it("unmarkPaid drops paid confirmation (Go endpoint reports no subscription)", () => { + let p = emptyFreeAccountPolicy(); + p = markPaid(p, "P", NOW); + p = unmarkPaid(p, "P"); + assert.equal(isPaid(p, "P", NOW), false); + p = markFreeUsage(p, "P", NOW); + assert.equal(countFreeAccounts(p, NOW), 1); + }); + + it("removing a deleted account drops the count back below the limit", () => { + let p = emptyFreeAccountPolicy(); + p = markFreeUsage(p, "A", NOW); + p = markFreeUsage(p, "B", NOW); + assert.equal(shouldBlockFreeUsage(p, NOW), true); + p = removeAccount(p, "B"); + assert.equal(countFreeAccounts(p, NOW), 1); + assert.equal(shouldBlockFreeUsage(p, NOW), false); + }); + + it("round-trips through the persisted shape", () => { + let p = emptyFreeAccountPolicy(); + p = markFreeUsage(p, "A", NOW); + p = markPaid(p, "P", NOW); + const stored = toStored(p); + assert.deepEqual(stored.free, ["A"]); + assert.equal(stored.paid["P"], NOW); + + const restored = fromStored(stored.free, stored.paid); + assert.equal(countFreeAccounts(restored, NOW), 1); + assert.equal(isPaid(restored, "P", NOW), true); + }); + + it("ignores malformed persisted data", () => { + const restored = fromStored(["ok", 42 as unknown as string, null as unknown as string], { P: "not-a-number" as unknown as number }); + assert.deepEqual([...restored.freeAccounts], ["ok"]); + assert.equal(restored.paidAccounts.size, 0); + }); +}); diff --git a/src/usage/freeAccountPolicy.ts b/src/usage/freeAccountPolicy.ts new file mode 100644 index 0000000..1bdcebc --- /dev/null +++ b/src/usage/freeAccountPolicy.ts @@ -0,0 +1,155 @@ +/** + * Single-free-account fair-use policy. + * + * OpenCode grants each person a free quota; harvesting that quota by + * registering multiple free accounts hurts everyone (inference is expensive). + * This policy enforces ONE free account per install: + * + * - A profile (identified by its API-key fingerprint) becomes a "free + * account" the moment a free model is used and it is not currently a + * confirmed paid account. + * - A profile is "confirmed paid" while it has an active subscription — + * detected via the Go usage endpoint (authoritative) or by successfully + * using a paid (non-free) model. Paid accounts never count toward the + * free limit, so multiple paid accounts are fine. + * - Confirmed-paid status expires after {@link PAID_CONFIRMATION_TTL_MS} + * without re-confirmation, so a lapsed subscription is re-classified as + * free (the user renews → paid usage re-confirms → exempt again). + * - Free-model usage is BLOCKED as soon as two free accounts are known, and + * stays blocked until the user keeps only one free account (delete the + * others via the profile commands) or one of them becomes paid. + * + * CONTRACT: pure — operates on plain Sets/Maps, no `vscode` import, no side + * effects. Unit-tested in plain Node. + * + * LIMITATION (documented honestly): this is a per-install deterrent, not an + * identity guarantee — two keys cannot be proven to belong to one person, and + * a determined user could bypass it on another machine or via the CLI. Its + * goal is to stop casual multi-account free-quota harvesting. + */ +import { PAID_CONFIRMATION_TTL_MS } from "../config"; + +export interface FreeAccountPolicy { + /** Fingerprints currently treated as free accounts. */ + freeAccounts: Set; + /** Fingerprint → last time it was confirmed as a paid account (ms). */ + paidAccounts: Map; +} + +export function emptyFreeAccountPolicy(): FreeAccountPolicy { + return { freeAccounts: new Set(), paidAccounts: new Map() }; +} + +/** Whether `fingerprint` is currently a live confirmed paid account. */ +export function isPaid(policy: FreeAccountPolicy, fingerprint: string, nowMs: number): boolean { + const confirmedAt = policy.paidAccounts.get(fingerprint); + if (confirmedAt === undefined) { + return false; + } + return nowMs - confirmedAt < PAID_CONFIRMATION_TTL_MS; +} + +/** Count free accounts — freeAccounts entries that are not currently confirmed paid. */ +export function countFreeAccounts(policy: FreeAccountPolicy, nowMs: number): number { + let count = 0; + for (const fp of policy.freeAccounts) { + if (!isPaid(policy, fp, nowMs)) { + count += 1; + } + } + return count; +} + +/** + * Record that a free model was used under `fingerprint`. Paid accounts are + * exempt (they may use free models without counting toward the limit). + * Returns the SAME reference when nothing changed. + */ +export function markFreeUsage(policy: FreeAccountPolicy, fingerprint: string, nowMs: number): FreeAccountPolicy { + if (isPaid(policy, fingerprint, nowMs) || policy.freeAccounts.has(fingerprint)) { + return policy; + } + const next: FreeAccountPolicy = { + freeAccounts: new Set(policy.freeAccounts), + paidAccounts: new Map(policy.paidAccounts), + }; + next.freeAccounts.add(fingerprint); + return next; +} + +/** + * Confirm `fingerprint` as a paid account (active subscription / successful + * paid-model usage). Removes it from the free-account set. Returns the SAME + * reference when nothing changed. + */ +export function markPaid(policy: FreeAccountPolicy, fingerprint: string, nowMs: number): FreeAccountPolicy { + const isCurrentlyPaid = isPaid(policy, fingerprint, nowMs); + if (isCurrentlyPaid && !policy.freeAccounts.has(fingerprint)) { + return policy; + } + const next: FreeAccountPolicy = { + freeAccounts: new Set(policy.freeAccounts), + paidAccounts: new Map(policy.paidAccounts), + }; + next.paidAccounts.set(fingerprint, nowMs); + next.freeAccounts.delete(fingerprint); + return next; +} + +/** + * Drop the paid confirmation (subscription lapsed / endpoint reports no + * subscription). The account becomes free only when it next uses a free model. + * Returns the SAME reference when nothing changed. + */ +export function unmarkPaid(policy: FreeAccountPolicy, fingerprint: string): FreeAccountPolicy { + if (!policy.paidAccounts.has(fingerprint)) { + return policy; + } + const next: FreeAccountPolicy = { + freeAccounts: new Set(policy.freeAccounts), + paidAccounts: new Map(policy.paidAccounts), + }; + next.paidAccounts.delete(fingerprint); + return next; +} + +/** + * Whether free-model usage must be blocked: true once two or more free + * accounts are known. The user must keep only one free account (delete the + * others) or make the extra ones paid before free usage works again. + */ +export function shouldBlockFreeUsage(policy: FreeAccountPolicy, nowMs: number): boolean { + return countFreeAccounts(policy, nowMs) >= 2; +} + +/** Remove a deleted profile from both sets (free-account count drops). */ +export function removeAccount(policy: FreeAccountPolicy, fingerprint: string): FreeAccountPolicy { + if (!policy.freeAccounts.has(fingerprint) && !policy.paidAccounts.has(fingerprint)) { + return policy; + } + const next: FreeAccountPolicy = { + freeAccounts: new Set(policy.freeAccounts), + paidAccounts: new Map(policy.paidAccounts), + }; + next.freeAccounts.delete(fingerprint); + next.paidAccounts.delete(fingerprint); + return next; +} + +/** Rebuild a policy from the persisted (JSON-safe) shape. */ +export function fromStored(free: string[] | undefined, paid: Record | undefined): FreeAccountPolicy { + const paidEntries = Object.entries(paid ?? {}).filter((entry): entry is [string, number] => typeof entry[1] === "number"); + return { + freeAccounts: new Set(Array.isArray(free) ? free.filter((fp): fp is string => typeof fp === "string") : []), + paidAccounts: new Map(paidEntries), + }; +} + +/** Serialize a policy to the persisted (JSON-safe) shape. */ +export function toStored(policy: FreeAccountPolicy): { free: string[]; paid: Record } { + const paid: Record = {}; + for (const [fp, at] of policy.paidAccounts) { + paid[fp] = at; + } + return { free: [...policy.freeAccounts], paid }; +} diff --git a/src/usage/freeAccountStorage.ts b/src/usage/freeAccountStorage.ts new file mode 100644 index 0000000..f9e6092 --- /dev/null +++ b/src/usage/freeAccountStorage.ts @@ -0,0 +1,37 @@ +/** + * Persistence for the single-free-account policy. + * + * Thin wrapper over globalState so the pure policy logic + * (`./freeAccountPolicy.ts`) stays unit-testable and every caller reads and + * writes through the same keys. + */ +import * as vscode from "vscode"; +import { FREE_ACCOUNTS_STATE_KEY, PAID_ACCOUNTS_STATE_KEY } from "../config"; +import { fromStored, shouldBlockFreeUsage, toStored, type FreeAccountPolicy } from "./freeAccountPolicy"; + +/** Load the current policy from globalState. */ +export function loadFreeAccountPolicy(context: vscode.ExtensionContext): FreeAccountPolicy { + return fromStored( + context.globalState.get(FREE_ACCOUNTS_STATE_KEY, []), + context.globalState.get>(PAID_ACCOUNTS_STATE_KEY, {}), + ); +} + +/** Persist the policy to globalState (fire-and-forget like other state). */ +export function persistFreeAccountPolicy(context: vscode.ExtensionContext, policy: FreeAccountPolicy): void { + const stored = toStored(policy); + void context.globalState.update(FREE_ACCOUNTS_STATE_KEY, stored.free); + void context.globalState.update(PAID_ACCOUNTS_STATE_KEY, stored.paid); +} + +/** Human-readable diagnostics for the policy (fingerprints are key hashes, safe to show). */ +export function freeAccountPolicyDiagnostics(context: vscode.ExtensionContext): string[] { + const policy = loadFreeAccountPolicy(context); + const free = [...policy.freeAccounts]; + const paid = [...policy.paidAccounts.keys()]; + return [ + `- freeAccounts (${String(free.length)}): ${free.length ? free.join(", ") : "none"}`, + `- paidAccounts (${String(paid.length)}): ${paid.length ? paid.join(", ") : "none"}`, + `- freeUsageBlocked: ${String(shouldBlockFreeUsage(policy, Date.now()))}`, + ]; +} From 052906d738861eb4a6356a7d3c4694eb46a205a8 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Sat, 15 Aug 2026 18:40:29 +0500 Subject: [PATCH 2/4] feat(provider): block free-model usage when multiple free accounts are detected --- src/provider/OpenCodeProvider.ts | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/provider/OpenCodeProvider.ts b/src/provider/OpenCodeProvider.ts index 04cd2cd..7c1cb09 100644 --- a/src/provider/OpenCodeProvider.ts +++ b/src/provider/OpenCodeProvider.ts @@ -73,6 +73,9 @@ import { } from "../usage/dashboard"; import { formatCacheHitRatio } from "../usage/usage"; import { estimateCost } from "../usage/pricing"; +import { countFreeAccounts, markFreeUsage, markPaid, shouldBlockFreeUsage } from "../usage/freeAccountPolicy"; +import { freeAccountPolicyDiagnostics, loadFreeAccountPolicy, persistFreeAccountPolicy } from "../usage/freeAccountStorage"; +import { keyFingerprint } from "../usage/usageProfile"; import { resolveResponseApiKey } from "../apiKeyResolution"; import { clearOpenCodeModelMetadataCache, getOpenCodeModelMetadata } from "../models/metadataFetcher"; import { convertMessage, normalizeMessages, trimOldImagesFromHistoryInPlace } from "./messages"; @@ -475,6 +478,10 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider convertMessage(message, this.reasoningContentByToolCallId, rawModelId)), ); @@ -860,6 +893,16 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider { + // A successful paid (non-free) model request confirms a paid account and + // exempts it from the single-free-account limit (multiple paid accounts + // are allowed). Only requests with actual token usage count as success. + if (!isFreeModel(summary.modelId) && (summary.promptTokens !== undefined || summary.completionTokens !== undefined)) { + const paidPolicy = loadFreeAccountPolicy(this.context); + const nextPaidPolicy = markPaid(paidPolicy, keyFingerprint(apiKey), Date.now()); + if (nextPaidPolicy !== paidPolicy) { + persistFreeAccountPolicy(this.context, nextPaidPolicy); + } + } // Compute credits for VS Code session cost (1 credit = $0.01). // VS Code reads usage.copilotCredits from the LanguageModelDataPart // to accumulate session cost. We mutate the summary object directly From 59cedfeae78d79ffdaee5b13834e5c6d4fbd2da9 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Sat, 15 Aug 2026 18:40:42 +0500 Subject: [PATCH 3/4] feat(usage): classify Go accounts as paid from the subscription sync --- src/usage/dashboard.ts | 21 +++++++++++++++++++++ src/usage/tracker.ts | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/usage/dashboard.ts b/src/usage/dashboard.ts index 08c46da..7922bf2 100644 --- a/src/usage/dashboard.ts +++ b/src/usage/dashboard.ts @@ -40,6 +40,8 @@ import { writeProfiles, type UsageProfile, } from "./usageProfile"; +import { markPaid, unmarkPaid } from "./freeAccountPolicy"; +import { loadFreeAccountPolicy, persistFreeAccountPolicy } from "./freeAccountStorage"; import { escapeHtml, formatCount, formatRelativeTime, formatTokenCount, formatUsd } from "../utils"; export let usageStatusBarItem: vscode.StatusBarItem | undefined; @@ -344,10 +346,28 @@ export function refreshGoUsageStatusBar(): void { const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(secretKeyFor(GO_VENDOR))); if (!apiKey) return; const changed = await tracker.syncServerUsage(apiKey); + applyGoSubscriptionToPolicy(tracker, apiKey); if (changed) refreshGoUsageStatusBar(); })(); } +/** + * Reflect the tracker's last Go-subscription classification in the + * free-account policy: active sub → confirmed paid; no sub → drop paid. + */ +function applyGoSubscriptionToPolicy(tracker: GoUsageTracker, apiKey: string): void { + const subActive = tracker.lastSubscriptionActive; + if (subActive === undefined || !_extensionContext) { + return; + } + const policy = loadFreeAccountPolicy(_extensionContext); + const fingerprint = keyFingerprint(apiKey); + const next = subActive ? markPaid(policy, fingerprint, Date.now()) : unmarkPaid(policy, fingerprint); + if (next !== policy) { + persistFreeAccountPolicy(_extensionContext, next); + } +} + /** * Fetch server-accurate usage for a key and repaint the status bar when a new * snapshot arrived. Uses the tracker owning that key (creating its profile on @@ -355,6 +375,7 @@ export function refreshGoUsageStatusBar(): void { */ export async function syncTrackerUsage(tracker: GoUsageTracker, apiKey: string): Promise { const changed = await tracker.syncServerUsage(apiKey); + applyGoSubscriptionToPolicy(tracker, apiKey); if (changed) refreshGoUsageStatusBar(); } diff --git a/src/usage/tracker.ts b/src/usage/tracker.ts index abe76fe..d4bf9a7 100644 --- a/src/usage/tracker.ts +++ b/src/usage/tracker.ts @@ -280,9 +280,19 @@ export class GoUsageTracker { private serverUsageFetchedAt = 0; /** In-flight sync promise per key — prevents duplicate concurrent fetches. */ private syncInFlight: { apiKey: string; promise: Promise } | undefined; + /** Last classified Go-subscription status from the usage endpoint (for the free-account policy). */ + private subscriptionActive: boolean | undefined; private static readonly SESSION_IDLE_MS = GO_SESSION_IDLE_MS; private static readonly MAX_SESSIONS = GO_MAX_SESSIONS; + /** + * Whether the last usage sync reported an ACTIVE Go subscription. + * `true` = paid, `false` = no subscription, `undefined` = not yet known. + */ + get lastSubscriptionActive(): boolean | undefined { + return this.subscriptionActive; + } + constructor( private readonly context: vscode.ExtensionContext, log?: (msg: string) => void, @@ -544,6 +554,14 @@ export class GoUsageTracker { // Pace retries after failures too — an invalid key or unreachable // endpoint must not hammer the API on every request. this.serverUsageFetchedAt = Date.now(); + // Classify the account's subscription status for the free-account policy: + // a 200 is an active Go subscription; a 403 is a lapsed/absent one. Other + // failures (network, 401, 404, invalid) leave the previous classification. + if (result.ok) { + this.subscriptionActive = true; + } else if (result.reason === "no-subscription") { + this.subscriptionActive = false; + } if (!result.ok) { this.log?.(`[go-usage] Server usage sync skipped (${result.reason}); keeping local estimates.`); return false; From 77f5aef4938287e1b57e1adb59abfac97492a227 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Sat, 15 Aug 2026 18:40:54 +0500 Subject: [PATCH 4/4] feat(usage): clean free-account policy on profile delete and warn in quick pick --- src/extension.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index d5952d3..2ffaba2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,8 @@ import { secretKeyFor, } from "./config"; import { GoUsageTracker } from "./usage/tracker"; +import { removeAccount, shouldBlockFreeUsage } from "./usage/freeAccountPolicy"; +import { loadFreeAccountPolicy, persistFreeAccountPolicy } from "./usage/freeAccountStorage"; import { buildUsageQuickPickItems } from "./usage/formatting"; import { PROVIDERS } from "./provider/definitions"; import { OpenCodeProvider } from "./provider/OpenCodeProvider"; @@ -201,6 +203,15 @@ export function activate(context: vscode.ExtensionContext) { const summary = tracker.getSummary(); const items = buildUsageQuickPickItems(summary, tracker.hasServerUsage, usageRollingMeterVisible()); + // Warn when free usage is blocked by the single-free-account policy. + if (shouldBlockFreeUsage(loadFreeAccountPolicy(context), Date.now())) { + items.unshift({ + label: "$(warning) Multiple free accounts detected", + detail: "Free models are disabled until you keep only one free account (or add a paid subscription).", + alwaysShow: true, + }); + } + // All-time usage in the current workspace (from the OpenCode CLI // history) — replaces the old "Latest Session (est)" estimate row. if (usageCodebaseRowVisible()) { @@ -317,6 +328,14 @@ export function activate(context: vscode.ExtensionContext) { ctx.globalState.update(`opencodego.usageBaseline.v1.${fp}`, {}); ctx.globalState.update(`opencodego.sessionCosts.v1.${fp}`, []); + // Drop the deleted profile from the free-account policy so the + // free-account count drops back toward the allowed single account. + const policy = loadFreeAccountPolicy(ctx); + const nextPolicy = removeAccount(policy, fp); + if (nextPolicy !== policy) { + persistFreeAccountPolicy(ctx, nextPolicy); + } + const remaining = readProfiles(ctx).filter((p) => p.fingerprint !== fp); await writeProfiles(ctx, remaining); setProfilesCache(remaining);