From 8685b539acc288c3c75d0537feb57cd0d2018520 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 17 Apr 2026 11:36:50 +0530 Subject: [PATCH 01/59] feat(provider): add usage limits and retries --- apps/server/src/persistence/Migrations.ts | 5 + .../021_AuthSessionClientMetadata.ts | 12 + .../022_AuthSessionLastConnectedAt.ts | 11 + .../026_AuthAccessManagementCompat.ts | 53 ++++ .../src/provider/Layers/ClaudeProvider.ts | 112 +++++++- .../src/provider/Layers/CodexProvider.ts | 52 +++- .../provider/Layers/ProviderRegistry.test.ts | 174 +++++++++++ .../src/provider/claudeUsageProbe.test.ts | 222 ++++++++++++++ apps/server/src/provider/claudeUsageProbe.ts | 272 ++++++++++++++++++ .../src/provider/codexAppServer.test.ts | 90 ++++++ apps/server/src/provider/codexAppServer.ts | 217 +++++++++++--- apps/server/src/provider/providerSnapshot.ts | 3 + .../src/provider/providerStatusCache.test.ts | 13 + .../src/provider/providerStatusCache.ts | 1 + .../src/provider/providerUsageLimits.test.ts | 74 +++++ .../src/provider/providerUsageLimits.ts | 121 ++++++++ .../settings/SettingsPanels.browser.tsx | 179 ++++++++++++ .../components/settings/SettingsPanels.tsx | 119 +++++++- packages/contracts/src/server.test.ts | 91 ++++++ packages/contracts/src/server.ts | 23 ++ 20 files changed, 1804 insertions(+), 40 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/026_AuthAccessManagementCompat.ts create mode 100644 apps/server/src/provider/claudeUsageProbe.test.ts create mode 100644 apps/server/src/provider/claudeUsageProbe.ts create mode 100644 apps/server/src/provider/codexAppServer.test.ts create mode 100644 apps/server/src/provider/providerUsageLimits.test.ts create mode 100644 apps/server/src/provider/providerUsageLimits.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 023e3bca051..a348f2ce9d9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -38,6 +38,7 @@ import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; +import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; /** * Migration loader with all migrations defined inline. @@ -49,6 +50,9 @@ import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingAppro * Uses Migrator.fromRecord which parses the key format and * returns migrations sorted by ID. */ +// Keep this as the single source of truth for migration ordering until we add +// generation or discovery for the static registry. Each new migration must be +// added in one place here plus its matching import above. export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], [2, "OrchestrationCommandReceipts", Migration0002], @@ -75,6 +79,7 @@ export const migrationEntries = [ [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], + [26, "AuthAccessManagementCompat", Migration0026], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts index 3b387fdcfd2..ff27c1ca1de 100644 --- a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts +++ b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts @@ -4,6 +4,18 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const existingTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name IN ('auth_pairing_links', 'auth_sessions') + `; + const tableNames = new Set(existingTables.map((table) => table.name)); + + if (!tableNames.has("auth_pairing_links") || !tableNames.has("auth_sessions")) { + return; + } + const pairingLinkColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(auth_pairing_links) `; diff --git a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts index e806a073a55..8b21ef845f6 100644 --- a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts +++ b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts @@ -4,6 +4,17 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const existingTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'auth_sessions' + `; + + if (existingTables.length === 0) { + return; + } + const sessionColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(auth_sessions) `; diff --git a/apps/server/src/persistence/Migrations/026_AuthAccessManagementCompat.ts b/apps/server/src/persistence/Migrations/026_AuthAccessManagementCompat.ts new file mode 100644 index 00000000000..c2305be6ee5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/026_AuthAccessManagementCompat.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Compatibility repair for databases where migration ID 20 was already consumed +// before auth access tables were introduced. This recreates the intended schema +// without disturbing databases that already applied the auth migrations normally. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_pairing_links ( + id TEXT PRIMARY KEY, + credential TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + role TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT, + revoked_at TEXT, + label TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active + ON auth_pairing_links(revoked_at, consumed_at, expires_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + role TEXT NOT NULL, + method TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + client_label TEXT, + client_ip_address TEXT, + client_user_agent TEXT, + client_device_type TEXT NOT NULL DEFAULT 'unknown', + client_os TEXT, + client_browser TEXT, + last_connected_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_sessions_active + ON auth_sessions(revoked_at, expires_at, issued_at) + `; +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index c6135fe247b..afc2522e00a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -6,8 +6,9 @@ import type { ServerProviderAuth, ServerProviderSlashCommand, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; -import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; +import { Cache, Duration, Effect, Equal, Layer, Option, Ref, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { @@ -27,10 +28,12 @@ import { type CommandResult, } from "../providerSnapshot.ts"; import { compareCliVersions } from "../cliVersion.ts"; +import { parseClaudeUsageLimitsOutput, probeClaudeUsageLimits } from "../claudeUsageProbe.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], @@ -516,6 +519,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( resolveSlashCommands?: ( binaryPath: string, ) => Effect.Effect | undefined>, + resolveUsageLimits?: (input: { + readonly binaryPath: string; + readonly launchArgs: string; + readonly checkedAt: string; + }) => Effect.Effect, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -628,6 +636,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) : undefined) ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + const resolvedUsageLimits = + (resolveUsageLimits + ? yield* resolveUsageLimits({ + binaryPath: claudeSettings.binaryPath, + launchArgs: claudeSettings.launchArgs, + checkedAt, + }).pipe(Effect.orElseSucceed(() => undefined)) + : undefined) ?? undefined; // ── Auth check + subscription detection ──────────────────────────── @@ -654,6 +670,20 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); } + const usageLimits = + normalizeClaudeAuthMethod(authMethod) === "apiKey" + ? makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt, + reason: "Usage limits unavailable for Claude API key accounts.", + }) + : (resolvedUsageLimits ?? + makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt, + reason: "Usage limits unavailable for this Claude account.", + })); + // ── Handle auth results (same logic as before, adjusted models) ── if (Result.isFailure(authProbe)) { @@ -673,6 +703,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( error instanceof Error ? `Could not verify Claude authentication status: ${error.message}.` : "Could not verify Claude authentication status.", + usageLimits, }, }); } @@ -690,6 +721,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( status: "warning", auth: { status: "unknown" }, message: "Could not verify Claude authentication status. Timed out while running command.", + usageLimits, }, }); } @@ -710,6 +742,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...parsed.auth, ...(authMetadata ? authMetadata : {}), }, + usageLimits, ...(parsed.message ? { message: parsed.message } : opus47UpgradeMessage @@ -764,12 +797,62 @@ export const ClaudeProviderLive = Layer.effect( Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const usageProbeStateRef = yield* Ref.make( + new Map(), + ); + const usageProbeTtlMs = 5 * 60 * 1000; const subscriptionProbeCache = yield* Cache.make({ capacity: 1, timeToLive: Duration.minutes(5), lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), }); + const refreshUsageProbe = (key: string, binaryPath: string, launchArgs: string) => + Effect.gen(function* () { + yield* Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + const existing = next.get(key); + next.set(key, { + rawOutput: existing?.rawOutput ?? "", + fetchedAtMs: existing?.fetchedAtMs ?? 0, + inFlight: true, + }); + return next; + }); + + const rawOutput = yield* Effect.tryPromise(() => + probeClaudeUsageLimits({ + binaryPath, + launchArgs, + cwd: process.cwd(), + checkedAt: "", + }), + ).pipe( + Effect.map((result) => result.rawOutput), + Effect.orElseSucceed(() => ""), + ); + + yield* Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + next.set(key, { + rawOutput, + fetchedAtMs: Date.now(), + inFlight: false, + }); + return next; + }); + }).pipe( + Effect.ensuring( + Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + const existing = next.get(key); + if (existing) { + next.set(key, { ...existing, inFlight: false }); + } + return next; + }), + ), + ); const checkProvider = checkClaudeProviderStatus( (binaryPath) => @@ -780,6 +863,33 @@ export const ClaudeProviderLive = Layer.effect( Cache.get(subscriptionProbeCache, binaryPath).pipe( Effect.map((probe) => probe?.slashCommands), ), + (input) => + Effect.gen(function* () { + const key = JSON.stringify([input.binaryPath, input.launchArgs]); + const entry = (yield* Ref.get(usageProbeStateRef)).get(key); + const isFresh = entry !== undefined && Date.now() - entry.fetchedAtMs < usageProbeTtlMs; + + if ((!entry || !isFresh) && !entry?.inFlight) { + yield* Effect.sync(() => { + void Effect.runPromiseExit( + refreshUsageProbe(key, input.binaryPath, input.launchArgs), + ); + }); + } + + if (!entry) { + return makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Usage limits are still loading for this Claude account.", + }); + } + + return parseClaudeUsageLimitsOutput({ + output: entry.rawOutput, + checkedAt: input.checkedAt, + }); + }), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index de4aceeac96..275c365a2e8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -7,6 +7,7 @@ import type { ServerProviderAuth, ServerProviderSkill, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import { Cache, @@ -45,10 +46,11 @@ import { codexAuthSubType, type CodexAccountSnapshot, } from "../codexAccount.ts"; -import { probeCodexDiscovery } from "../codexAppServer.ts"; +import { normalizeCodexUsageLimits, probeCodexDiscovery } from "../codexAppServer.ts"; import { CodexProvider } from "../Services/CodexProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [ @@ -341,6 +343,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly homePath?: string; readonly cwd: string; }) => Effect.Effect | undefined>, + resolveUsageLimits?: (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly checkedAt: string; + }) => Effect.Effect, ): Effect.fn.Return< ServerProvider, ServerSettingsError, @@ -465,6 +473,16 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }).pipe(Effect.orElseSucceed(() => undefined)) : undefined) ?? []; + const resolvedUsageLimits = + (resolveUsageLimits + ? yield* resolveUsageLimits({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + cwd: process.cwd(), + checkedAt, + }).pipe(Effect.orElseSucceed(() => undefined)) + : undefined) ?? undefined; + if (yield* hasCustomModelProvider) { return buildServerProvider({ provider: PROVIDER, @@ -478,6 +496,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: "ready", auth: { status: "unknown" }, message: "Using a custom Codex model provider; OpenAI login check skipped.", + usageLimits: makeUnavailableUsageLimits({ + source: "codexAppServer", + checkedAt, + reason: "Usage limits unavailable for custom Codex model providers.", + }), }, }); } @@ -493,6 +516,19 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }) : undefined; const resolvedModels = adjustCodexModelsForAccount(models, account); + const usageLimits = + account?.type === "apiKey" + ? makeUnavailableUsageLimits({ + source: "codexAppServer", + checkedAt, + reason: "Usage limits unavailable for API key Codex accounts.", + }) + : (resolvedUsageLimits ?? + makeUnavailableUsageLimits({ + source: "codexAppServer", + checkedAt, + reason: "Codex usage limits could not be read for this account.", + })); if (Result.isFailure(authProbe)) { const error = authProbe.failure; @@ -508,6 +544,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: "warning", auth: { status: "unknown" }, message: `Could not verify Codex authentication status: ${error.message}.`, + usageLimits, }, }); } @@ -525,6 +562,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: "warning", auth: { status: "unknown" }, message: "Could not verify Codex authentication status. Timed out while running command.", + usageLimits, }, }); } @@ -548,6 +586,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ...(authLabel ? { label: authLabel } : {}), }, ...(parsed.message ? { message: parsed.message } : {}), + usageLimits, }, }); }); @@ -626,6 +665,17 @@ export const CodexProviderLive = Layer.effect( cwd: process.cwd(), }).pipe(Effect.map((discovery) => discovery?.account)), (input) => getDiscovery(input).pipe(Effect.map((discovery) => discovery?.skills)), + (input) => + getDiscovery(input).pipe( + Effect.map((discovery) => + discovery + ? normalizeCodexUsageLimits({ + checkedAt: input.checkedAt, + ...(discovery.rateLimits ? { rateLimits: discovery.rateLimits } : {}), + }) + : undefined, + ), + ), ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 170521d2d27..a2de09f9f82 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -265,6 +265,96 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes codex subscription usage windows", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus( + () => + Effect.succeed({ + type: "chatgpt" as const, + planType: "pro" as const, + sparkEnabled: true, + }), + undefined, + ({ checkedAt }) => + Effect.succeed({ + source: "codexAppServer" as const, + available: true, + checkedAt, + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 28, + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 61, + }, + ], + }), + ); + + assert.deepStrictEqual( + status.usageLimits?.windows.map((window) => window.kind), + ["session", "weekly"], + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable usage for codex api key accounts", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus( + () => + Effect.succeed({ + type: "apiKey" as const, + planType: null, + sparkEnabled: false, + }), + undefined, + ({ checkedAt }) => + Effect.succeed({ + source: "codexAppServer" as const, + available: true, + checkedAt, + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 99, + }, + ], + }), + ); + + assert.strictEqual(status.usageLimits?.available, false); + assert.strictEqual( + status.usageLimits?.reason, + "Usage limits unavailable for API key Codex accounts.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides spark from codex models for unsupported chatgpt plans", () => Effect.gen(function* () { yield* withTempCodexHome(); @@ -1140,6 +1230,90 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("includes parsed claude usage windows", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + undefined, + ({ checkedAt }) => + Effect.succeed({ + source: "claudeStatusProbe" as const, + available: true, + checkedAt, + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 35, + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 52, + }, + ], + }), + ); + + assert.deepStrictEqual( + status.usageLimits?.windows.map((window) => window.kind), + ["session", "weekly"], + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps claude healthy when usage parsing fails", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + () => Effect.succeed("maxplan"), + undefined, + ({ checkedAt }) => + Effect.succeed({ + source: "claudeStatusProbe" as const, + available: false, + checkedAt, + reason: "Usage limits unavailable for this Claude account.", + windows: [], + }), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.usageLimits?.available, false); + assert.strictEqual( + status.usageLimits?.reason, + "Usage limits unavailable for this Claude account.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns an api key label for claude api key auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts new file mode 100644 index 00000000000..6afbf157623 --- /dev/null +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type Disposable = { readonly dispose: () => void }; + +class MockPtyChild { + public readonly writes: string[] = []; + public readonly kill = vi.fn(); + + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<() => void>(); + + public onData(listener: (data: string) => void): Disposable { + this.dataListeners.add(listener); + return { + dispose: () => { + this.dataListeners.delete(listener); + }, + }; + } + + public onExit(listener: () => void): Disposable { + this.exitListeners.add(listener); + return { + dispose: () => { + this.exitListeners.delete(listener); + }, + }; + } + + public write(data: string): void { + this.writes.push(data); + } + + public emitData(data: string): void { + for (const listener of this.dataListeners) { + listener(data); + } + } + + public emitExit(): void { + for (const listener of this.exitListeners) { + listener(); + } + } +} + +const spawnMock = vi.fn< + (file: string, args?: readonly string[], options?: Record) => MockPtyChild +>(() => new MockPtyChild()); + +vi.mock("node-pty", () => ({ + spawn: spawnMock, +})); + +import { + parseClaudeUsageLimitsOutput, + probeClaudeUsageLimits, + shouldRequestClaudeUsageFallback, +} from "./claudeUsageProbe.ts"; + +function latestSpawnedChild(): MockPtyChild { + const latest = spawnMock.mock.results.at(-1)?.value; + if (!latest) { + throw new Error("Expected node-pty spawn to be called."); + } + return latest; +} + +describe("claudeUsageProbe", () => { + beforeEach(() => { + vi.useFakeTimers(); + spawnMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("parses session and weekly windows from status output", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: ` + Session usage 42% resets at 2026-04-17T14:00:00Z + Weekly usage 68% resets at 2026-04-21T00:00:00Z + `, + }), + ).toEqual({ + source: "claudeStatusProbe", + available: true, + checkedAt: "2026-04-17T10:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 42, + windowDurationMins: 300, + resetsAt: "2026-04-17T14:00:00.000Z", + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 68, + windowDurationMins: 10080, + resetsAt: "2026-04-21T00:00:00.000Z", + }, + ], + }); + }); + + it("returns unavailable when quota text is absent", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Authenticated as Claude Max", + }), + ).toEqual({ + source: "claudeStatusProbe", + available: false, + checkedAt: "2026-04-17T10:00:00.000Z", + reason: "Usage limits unavailable for this Claude account.", + windows: [], + }); + }); + + it("requests the /usage fallback for short unavailable status output", () => { + expect( + shouldRequestClaudeUsageFallback({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Authenticated as Claude Max\n", + }), + ).toBe(true); + }); + + it("requests the /usage fallback even when output is empty", () => { + expect( + shouldRequestClaudeUsageFallback({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "", + }), + ).toBe(true); + }); + + it("skips the /usage fallback once usage windows are already available", () => { + expect( + shouldRequestClaudeUsageFallback({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Session usage 42% resets at 2026-04-17T14:00:00Z\n", + }), + ).toBe(false); + }); + + it("triggers /usage fallback when /status remains quiet", async () => { + const probePromise = probeClaudeUsageLimits({ + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }); + + const child = latestSpawnedChild(); + expect(child.writes).toEqual(["/status\r"]); + + await vi.advanceTimersByTimeAsync(150); + expect(child.writes).toEqual(["/status\r", "/usage\r"]); + + child.emitExit(); + const result = await probePromise; + expect(result.usageLimits.available).toBe(false); + }); + + it("triggers /usage fallback for short non-empty status output", async () => { + const probePromise = probeClaudeUsageLimits({ + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }); + + const child = latestSpawnedChild(); + child.emitData("Authenticated as Claude Max\n"); + + await vi.advanceTimersByTimeAsync(150); + expect(child.writes).toEqual(["/status\r", "/usage\r"]); + + child.emitExit(); + const result = await probePromise; + expect(result.usageLimits.available).toBe(false); + }); + + it("skips /usage fallback when /status already returns usable quota output", async () => { + const probePromise = probeClaudeUsageLimits({ + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }); + + const child = latestSpawnedChild(); + child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); + + const result = await probePromise; + expect(result.usageLimits.available).toBe(true); + expect(child.writes).toEqual(["/status\r"]); + }); + + it("times out cleanly when neither /status nor /usage yields usable quota data", async () => { + const probePromise = probeClaudeUsageLimits({ + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }); + + const child = latestSpawnedChild(); + await vi.advanceTimersByTimeAsync(150); + expect(child.writes).toEqual(["/status\r", "/usage\r"]); + + await vi.advanceTimersByTimeAsync(4_000); + const result = await probePromise; + + expect(result.usageLimits.available).toBe(false); + expect(result.rawOutput).toBe(""); + expect(child.writes.filter((entry) => entry === "/usage\r")).toHaveLength(1); + }); +}); diff --git a/apps/server/src/provider/claudeUsageProbe.ts b/apps/server/src/provider/claudeUsageProbe.ts new file mode 100644 index 00000000000..06d596f64a3 --- /dev/null +++ b/apps/server/src/provider/claudeUsageProbe.ts @@ -0,0 +1,272 @@ +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import { makeUnavailableUsageLimits, makeUsageLimitsSnapshot } from "./providerUsageLimits.ts"; + +const CLAUDE_USAGE_PROBE_TIMEOUT_MS = 4_000; +const CLAUDE_USAGE_FALLBACK_IDLE_MS = 150; +const ANSI_PATTERN = + // Matches common CSI / OSC ANSI escape sequences. + // eslint-disable-next-line no-control-regex + /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g; +const nodePtyModulePromise = import("node-pty"); + +export interface ClaudeUsageProbeResult { + readonly usageLimits: ServerProviderUsageLimits; + readonly rawOutput: string; +} + +export function shouldRequestClaudeUsageFallback(input: { + readonly output: string; + readonly checkedAt: string; + readonly fallbackAlreadySent?: boolean; +}): boolean { + if (input.fallbackAlreadySent) { + return false; + } + + const parsed = parseClaudeUsageLimitsOutput(input); + return !parsed.available; +} + +function stripAnsi(value: string): string { + return value.replaceAll(ANSI_PATTERN, ""); +} + +function parsePercent(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function inferWindowDurationMins(value: string): number | undefined { + const lower = value.toLowerCase(); + if (/\bweekly\b|\b7\s*(?:d|day|days)\b/.test(lower)) { + return 7 * 24 * 60; + } + if (/\b5\s*(?:h|hr|hrs|hour|hours)\b|\bsession\b/.test(lower)) { + return 5 * 60; + } + return undefined; +} + +function detectClaudeUsageWindowKind(value: string): "session" | "weekly" | undefined { + const lower = value.toLowerCase(); + if (/\bweekly\b|\b7\s*(?:d|day|days)\b/.test(lower)) { + return "weekly"; + } + if (/\b5\s*(?:h|hr|hrs|hour|hours)\b|\bsession\b/.test(lower)) { + return "session"; + } + return undefined; +} + +function extractResetTimestamp(value: string): string | undefined { + const resetMatch = value.match( + /\breset(?:s|ting)?(?:\s+(?:at|on|in))?[:\s-]*([A-Za-z]{3,9}[^,\n]*\d{1,2}[^,\n]*\d{2,4}[^,\n]*|\d{4}-\d{2}-\d{2}T[^\s,]+|\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}(?::\d{2})?(?:\s*[A-Z]{2,5})?)/i, + ); + const candidate = resetMatch?.[1]?.trim(); + if (!candidate) return undefined; + const parsed = Date.parse(candidate); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined; +} + +function parseClaudeUsageWindowSegment( + kind: "session" | "weekly", + segment: string, +): { + readonly label: string; + readonly usedPercent: number; + readonly windowDurationMins: number; + readonly resetsAt?: string; +} | null { + const percentMatch = segment.match(/(\d{1,3}(?:\.\d+)?)\s*%/); + const usedPercent = parsePercent(percentMatch?.[1]); + const windowDurationMins = inferWindowDurationMins(segment); + if (usedPercent === undefined || windowDurationMins === undefined) { + return null; + } + const resetsAt = extractResetTimestamp(segment); + + return { + label: kind === "session" ? "Session" : "Weekly", + usedPercent, + windowDurationMins, + ...(resetsAt ? { resetsAt } : {}), + }; +} + +function extractWindowSegments(output: string): ReadonlyArray<{ + readonly label: string; + readonly usedPercent: number; + readonly windowDurationMins: number; + readonly resetsAt?: string; +}> { + const lines = output + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean); + const windows = new Map<"session" | "weekly", (typeof lines)[number]>(); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const kind = detectClaudeUsageWindowKind(line); + if (!kind || windows.has(kind)) continue; + + const segmentLines = [line]; + for (let cursor = index + 1; cursor < lines.length && segmentLines.length < 3; cursor += 1) { + const candidate = lines[cursor]!; + if (detectClaudeUsageWindowKind(candidate)) { + break; + } + segmentLines.push(candidate); + } + const neighborhood = segmentLines.join(" "); + windows.set(kind, neighborhood); + } + + return [...windows.entries()].flatMap(([kind, segment]) => { + const parsed = parseClaudeUsageWindowSegment(kind, segment); + if (!parsed) { + return []; + } + + return [parsed]; + }); +} + +export function parseClaudeUsageLimitsOutput(input: { + readonly output: string; + readonly checkedAt: string; +}): ServerProviderUsageLimits { + const cleanedOutput = stripAnsi(input.output); + const lowerOutput = cleanedOutput.toLowerCase(); + + if (/\bapi key\b|\bapi-key\b/.test(lowerOutput)) { + return makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Usage limits unavailable for Claude API key accounts.", + }); + } + + const windows = extractWindowSegments(cleanedOutput); + if (windows.length === 0) { + return makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Usage limits unavailable for this Claude account.", + }); + } + + return makeUsageLimitsSnapshot({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + windows, + unavailableReason: "Usage limits unavailable for this Claude account.", + }); +} + +export async function probeClaudeUsageLimits(input: { + readonly binaryPath: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly checkedAt: string; +}): Promise { + const nodePty = await nodePtyModulePromise; + const probeArgs = [ + ...(input.launchArgs?.trim().split(/\s+/).filter(Boolean) ?? []), + "--permission-mode", + "plan", + ]; + + return await new Promise((resolve) => { + const child = nodePty.spawn(input.binaryPath, probeArgs, { + cwd: input.cwd, + cols: 120, + rows: 40, + env: process.env, + name: process.platform === "win32" ? "xterm-color" : "xterm-256color", + }); + let rawOutput = ""; + let sentFallback = false; + let settled = false; + let fallbackTimer: ReturnType | undefined; + + const scheduleFallback = () => { + if (sentFallback || settled) { + return; + } + if (fallbackTimer) { + clearTimeout(fallbackTimer); + } + fallbackTimer = setTimeout(() => { + fallbackTimer = undefined; + maybeRequestFallback(); + }, CLAUDE_USAGE_FALLBACK_IDLE_MS); + }; + + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (fallbackTimer) { + clearTimeout(fallbackTimer); + } + offData.dispose(); + offExit.dispose(); + try { + child.kill(); + } catch { + // Ignore kill failures during cleanup. + } + resolve({ + usageLimits: parseClaudeUsageLimitsOutput({ + output: rawOutput, + checkedAt: input.checkedAt, + }), + rawOutput, + }); + }; + + const maybeRequestFallback = () => { + if (sentFallback) return; + if ( + !shouldRequestClaudeUsageFallback({ + output: rawOutput, + checkedAt: input.checkedAt, + fallbackAlreadySent: sentFallback, + }) + ) { + finish(); + return; + } + sentFallback = true; + child.write("/usage\r"); + }; + + const timeout = setTimeout(() => { + finish(); + }, CLAUDE_USAGE_PROBE_TIMEOUT_MS); + + const offData = child.onData((data) => { + rawOutput += data; + const parsed = parseClaudeUsageLimitsOutput({ + output: rawOutput, + checkedAt: input.checkedAt, + }); + if (parsed.available) { + finish(); + return; + } + if (!sentFallback) { + scheduleFallback(); + } + }); + + const offExit = child.onExit(() => { + finish(); + }); + + child.write("/status\r"); + scheduleFallback(); + }); +} diff --git a/apps/server/src/provider/codexAppServer.test.ts b/apps/server/src/provider/codexAppServer.test.ts new file mode 100644 index 00000000000..8af14f0ab96 --- /dev/null +++ b/apps/server/src/provider/codexAppServer.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeCodexUsageLimits, readCodexRateLimitsSnapshot } from "./codexAppServer.ts"; + +describe("codexAppServer", () => { + it("parses account/rateLimits/read payloads", () => { + const snapshot = readCodexRateLimitsSnapshot({ + rateLimits: { + shortWindow: { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: 1_776_384_000, + }, + longWindow: { + usedPercent: 40, + windowDurationMins: 10_080, + resetsAt: 1_776_988_800, + }, + }, + }); + + expect(snapshot).toEqual({ + windows: [ + { + usedPercent: 25, + windowDurationMins: 300, + resetsAt: "2026-04-17T00:00:00.000Z", + }, + { + usedPercent: 40, + windowDurationMins: 10080, + resetsAt: "2026-04-24T00:00:00.000Z", + }, + ], + }); + }); + + it("prefers rateLimitsByLimitId.codex when present", () => { + const snapshot = readCodexRateLimitsSnapshot({ + rateLimits: { + longWindow: { + usedPercent: 80, + windowDurationMins: 10_080, + }, + }, + rateLimitsByLimitId: { + codex: { + rateLimits: { + shortWindow: { + usedPercent: 20, + windowDurationMins: 300, + }, + longWindow: { + usedPercent: 30, + windowDurationMins: 10_080, + }, + }, + }, + }, + }); + + expect(snapshot).toEqual({ + windows: [ + { + usedPercent: 20, + windowDurationMins: 300, + }, + { + usedPercent: 30, + windowDurationMins: 10080, + }, + ], + }); + }); + + it("tolerates missing rate-limit responses", () => { + expect(readCodexRateLimitsSnapshot(undefined)).toBeUndefined(); + expect( + normalizeCodexUsageLimits({ + checkedAt: "2026-04-17T00:00:00.000Z", + }), + ).toEqual({ + source: "codexAppServer", + available: false, + reason: "No Codex subscription quota windows reported.", + windows: [], + checkedAt: "2026-04-17T00:00:00.000Z", + }); + }); +}); diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 24a9e29c592..020e9826737 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,7 +1,12 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; -import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { ServerProviderSkill, ServerProviderUsageLimits } from "@t3tools/contracts"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount.ts"; +import { + makeUsageLimitsSnapshot, + toIsoDateTimeFromUnixSeconds, + type RawUsageWindowInput, +} from "./providerUsageLimits.ts"; interface JsonRpcProbeResponse { readonly id?: unknown; @@ -14,6 +19,17 @@ interface JsonRpcProbeResponse { export interface CodexDiscoverySnapshot { readonly account: CodexAccountSnapshot; readonly skills: ReadonlyArray; + readonly rateLimits?: CodexRateLimitsSnapshot; +} + +export interface CodexRateLimitWindowSnapshot { + readonly usedPercent: number; + readonly windowDurationMins?: number; + readonly resetsAt?: string; +} + +export interface CodexRateLimitsSnapshot { + readonly windows: ReadonlyArray; } function readErrorMessage(response: JsonRpcProbeResponse): string | undefined { @@ -37,6 +53,86 @@ function nonEmptyTrimmed(value: unknown): string | undefined { return candidate ? candidate : undefined; } +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function collectCodexRateLimitWindows(value: unknown): ReadonlyArray { + const seen = new Set(); + const visit = (candidate: unknown): ReadonlyArray => { + if (Array.isArray(candidate)) { + return candidate.flatMap(visit); + } + + const record = readObject(candidate); + if (!record) { + return []; + } + if (seen.has(record)) { + return []; + } + seen.add(record); + + const usedPercent = readNumber(record.usedPercent); + if (usedPercent !== undefined) { + const windowDurationMins = readNumber(record.windowDurationMins); + const resetsAt = toIsoDateTimeFromUnixSeconds(readNumber(record.resetsAt)); + return [ + { + usedPercent, + ...(windowDurationMins !== undefined ? { windowDurationMins } : {}), + ...(resetsAt ? { resetsAt } : {}), + }, + ]; + } + + return Object.values(record).flatMap(visit); + }; + + const deduped = new Map(); + for (const window of visit(value)) { + const key = JSON.stringify(window); + if (!deduped.has(key)) { + deduped.set(key, window); + } + } + return [...deduped.values()]; +} + +export function readCodexRateLimitsSnapshot(result: unknown): CodexRateLimitsSnapshot | undefined { + const record = readObject(result); + const preferred = readObject(readObject(record?.rateLimitsByLimitId)?.codex); + const candidate = + preferred?.rateLimits ?? + record?.rateLimits ?? + preferred ?? + (record && readNumber(record.usedPercent) !== undefined ? record : undefined); + const windows = collectCodexRateLimitWindows(candidate); + return windows.length > 0 ? { windows } : undefined; +} + +export function normalizeCodexUsageLimits(input: { + readonly checkedAt: string; + readonly rateLimits?: CodexRateLimitsSnapshot; +}): ServerProviderUsageLimits { + const windows: RawUsageWindowInput[] = + input.rateLimits?.windows.map((window) => ({ + label: "Codex quota window", + usedPercent: window.usedPercent, + ...(window.resetsAt ? { resetsAt: window.resetsAt } : {}), + ...(typeof window.windowDurationMins === "number" + ? { windowDurationMins: window.windowDurationMins } + : {}), + })) ?? []; + + return makeUsageLimitsSnapshot({ + source: "codexAppServer", + checkedAt: input.checkedAt, + windows, + unavailableReason: "No Codex subscription quota windows reported.", + }); +} + function parseCodexSkillsResult(result: unknown, cwd: string): ReadonlyArray { const resultRecord = readObject(result); const dataBuckets = readArray(resultRecord?.data) ?? []; @@ -105,6 +201,68 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo child.kill(); } +interface CodexDiscoveryProbeState { + account?: CodexAccountSnapshot; + skills?: ReadonlyArray; + rateLimits: CodexRateLimitsSnapshot | undefined; + rateLimitsResponseReceived: boolean; +} + +function isCodexDiscoveryComplete(state: CodexDiscoveryProbeState): boolean { + return Boolean(state.account) && state.skills !== undefined && state.rateLimitsResponseReceived; +} + +function sendCodexDiscoveryRequests(writeMessage: (message: unknown) => void, cwd: string): void { + writeMessage({ method: "initialized" }); + writeMessage({ id: 2, method: "skills/list", params: { cwds: [cwd] } }); + writeMessage({ id: 3, method: "account/read", params: {} }); + writeMessage({ id: 4, method: "account/rateLimits/read", params: {} }); +} + +function applyCodexDiscoveryResponse(input: { + readonly response: JsonRpcProbeResponse; + readonly cwd: string; + readonly state: CodexDiscoveryProbeState; + readonly writeMessage: (message: unknown) => void; +}): { readonly shouldResolve: boolean } { + const { response, cwd, state, writeMessage } = input; + + if (response.id === 1) { + const errorMessage = readErrorMessage(response); + if (errorMessage) { + throw new Error(`initialize failed: ${errorMessage}`); + } + sendCodexDiscoveryRequests(writeMessage, cwd); + return { shouldResolve: false }; + } + + if (response.id === 2) { + const errorMessage = readErrorMessage(response); + state.skills = errorMessage ? [] : parseCodexSkillsResult(response.result, cwd); + return { shouldResolve: isCodexDiscoveryComplete(state) }; + } + + if (response.id === 3) { + const errorMessage = readErrorMessage(response); + if (errorMessage) { + throw new Error(`account/read failed: ${errorMessage}`); + } + state.account = readCodexAccountSnapshot(response.result); + return { shouldResolve: isCodexDiscoveryComplete(state) }; + } + + if (response.id === 4) { + const errorMessage = readErrorMessage(response); + if (!errorMessage) { + state.rateLimits = readCodexRateLimitsSnapshot(response.result); + } + state.rateLimitsResponseReceived = true; + return { shouldResolve: isCodexDiscoveryComplete(state) }; + } + + return { shouldResolve: false }; +} + export async function probeCodexDiscovery(input: { readonly binaryPath: string; readonly homePath?: string; @@ -123,8 +281,10 @@ export async function probeCodexDiscovery(input: { const output = readline.createInterface({ input: child.stdout }); let completed = false; - let account: CodexAccountSnapshot | undefined; - let skills: ReadonlyArray | undefined; + const state: CodexDiscoveryProbeState = { + rateLimits: undefined, + rateLimitsResponseReceived: false, + }; const cleanup = () => { output.removeAllListeners(); @@ -152,11 +312,16 @@ export async function probeCodexDiscovery(input: { ); const maybeResolve = () => { - if (account && skills !== undefined) { - const resolvedAccount = account; - const resolvedSkills = skills; - finish(() => resolve({ account: resolvedAccount, skills: resolvedSkills })); + if (!isCodexDiscoveryComplete(state)) { + return; } + finish(() => + resolve({ + account: state.account!, + skills: state.skills!, + ...(state.rateLimits ? { rateLimits: state.rateLimits } : {}), + }), + ); }; if (input.signal?.aborted) { @@ -189,36 +354,20 @@ export async function probeCodexDiscovery(input: { return; } - const response = parsed as JsonRpcProbeResponse; - if (response.id === 1) { - const errorMessage = readErrorMessage(response); - if (errorMessage) { - fail(new Error(`initialize failed: ${errorMessage}`)); - return; - } - - writeMessage({ method: "initialized" }); - writeMessage({ id: 2, method: "skills/list", params: { cwds: [input.cwd] } }); - writeMessage({ id: 3, method: "account/read", params: {} }); - return; - } - - if (response.id === 2) { - const errorMessage = readErrorMessage(response); - skills = errorMessage ? [] : parseCodexSkillsResult(response.result, input.cwd); - maybeResolve(); - return; - } - - if (response.id === 3) { - const errorMessage = readErrorMessage(response); - if (errorMessage) { - fail(new Error(`account/read failed: ${errorMessage}`)); + try { + const response = parsed as JsonRpcProbeResponse; + const next = applyCodexDiscoveryResponse({ + response, + cwd: input.cwd, + state, + writeMessage, + }); + if (!next.shouldResolve) { return; } - - account = readCodexAccountSnapshot(response.result); maybeResolve(); + } catch (error) { + fail(error); } }); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 068b7c11578..2e03cf29f59 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -6,6 +6,7 @@ import type { ServerProviderSlashCommand, ServerProviderModel, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import { Effect, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -26,6 +27,7 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + readonly usageLimits?: ServerProviderUsageLimits; } export function nonEmptyTrimmed(value: string | undefined): string | undefined { @@ -148,6 +150,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), }; } diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index a82cb4ae504..82e0396c0a6 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -65,6 +65,18 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { checkedAt: "2026-04-10T12:00:00.000Z", models: [], message: "Cached message", + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2026-04-10T12:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 12, + }, + ], + }, skills: [ { name: "github:gh-fix-ci", @@ -106,6 +118,7 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { checkedAt: cachedCodex.checkedAt, slashCommands: cachedCodex.slashCommands, skills: cachedCodex.skills, + usageLimits: cachedCodex.usageLimits, message: cachedCodex.message, }, ); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index abedf99d138..4d5e5a75c96 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -43,6 +43,7 @@ export const hydrateCachedProvider = (input: { checkedAt: input.cachedProvider.checkedAt, slashCommands: input.cachedProvider.slashCommands, skills: input.cachedProvider.skills, + usageLimits: input.cachedProvider.usageLimits, }; return input.cachedProvider.message diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts new file mode 100644 index 00000000000..d1f8beb1592 --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + clampPercent, + makeUsageLimitsSnapshot, + toIsoDateTimeFromUnixSeconds, + windowKindFromDuration, +} from "./providerUsageLimits.ts"; + +describe("providerUsageLimits", () => { + it("clamps percentages into the supported range", () => { + expect(clampPercent(-10)).toBe(0); + expect(clampPercent(42)).toBe(42); + expect(clampPercent(150)).toBe(100); + }); + + it("maps the shortest window to session and the longest to weekly", () => { + expect( + makeUsageLimitsSnapshot({ + source: "codexAppServer", + checkedAt: "2026-04-17T10:00:00.000Z", + unavailableReason: "missing", + windows: [ + { + label: "Five hour", + usedPercent: 10, + windowDurationMins: 300, + }, + { + label: "Seven day", + usedPercent: 20, + windowDurationMins: 10_080, + }, + ], + }).windows, + ).toEqual([ + { + kind: "session", + label: "Session", + usedPercent: 10, + windowDurationMins: 300, + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 20, + windowDurationMins: 10080, + }, + ]); + expect( + windowKindFromDuration({ + windowDurationMins: 300, + shortestWindowDurationMins: 300, + longestWindowDurationMins: 10080, + }), + ).toBe("session"); + expect( + windowKindFromDuration({ + windowDurationMins: 10080, + shortestWindowDurationMins: 300, + longestWindowDurationMins: 10080, + }), + ).toBe("weekly"); + }); + + it("normalizes unix-second reset timestamps", () => { + expect(toIsoDateTimeFromUnixSeconds(1_713_353_600)).toBe("2024-04-17T11:33:20.000Z"); + }); + + it("drops malformed or out-of-range unix-second reset timestamps", () => { + expect(toIsoDateTimeFromUnixSeconds(Number.MAX_VALUE)).toBeUndefined(); + expect(toIsoDateTimeFromUnixSeconds(Number.POSITIVE_INFINITY)).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts new file mode 100644 index 00000000000..c14d1b373e3 --- /dev/null +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -0,0 +1,121 @@ +import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; + +export interface RawUsageWindowInput { + readonly label: string; + readonly usedPercent: number; + readonly resetsAt?: string; + readonly windowDurationMins?: number; +} + +export function clampPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(100, value)); +} + +export function toIsoDateTimeFromUnixSeconds(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + const date = new Date(value * 1000); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; +} + +export function windowKindFromDuration(input: { + readonly windowDurationMins?: number; + readonly shortestWindowDurationMins?: number; + readonly longestWindowDurationMins?: number; +}): ServerProviderUsageWindow["kind"] | undefined { + const duration = input.windowDurationMins; + if (typeof duration !== "number" || !Number.isFinite(duration)) { + return undefined; + } + if (duration === input.shortestWindowDurationMins) { + return "session"; + } + if (duration === input.longestWindowDurationMins) { + return "weekly"; + } + return undefined; +} + +export function normalizeUsageWindows( + windows: ReadonlyArray, +): ReadonlyArray { + const normalizedDurations = windows + .map((window) => window.windowDurationMins) + .filter( + (duration): duration is number => typeof duration === "number" && Number.isFinite(duration), + ) + .toSorted((left, right) => left - right); + const shortestWindowDurationMins = normalizedDurations[0]; + const longestWindowDurationMins = normalizedDurations.at(-1); + + return windows + .flatMap((window) => { + const kind = windowKindFromDuration({ + ...(typeof window.windowDurationMins === "number" + ? { windowDurationMins: window.windowDurationMins } + : {}), + ...(typeof shortestWindowDurationMins === "number" ? { shortestWindowDurationMins } : {}), + ...(typeof longestWindowDurationMins === "number" ? { longestWindowDurationMins } : {}), + }); + if (!kind) { + return []; + } + return [ + { + kind, + label: kind === "session" ? "Session" : "Weekly", + usedPercent: clampPercent(window.usedPercent), + ...(window.resetsAt ? { resetsAt: window.resetsAt } : {}), + ...(typeof window.windowDurationMins === "number" && + Number.isFinite(window.windowDurationMins) + ? { windowDurationMins: Math.max(0, Math.round(window.windowDurationMins)) } + : {}), + } satisfies ServerProviderUsageWindow, + ]; + }) + .toSorted((left, right) => { + if (left.kind === right.kind) return 0; + return left.kind === "session" ? -1 : 1; + }); +} + +export function makeUnavailableUsageLimits(input: { + readonly source: ServerProviderUsageLimits["source"]; + readonly checkedAt: string; + readonly reason: string; +}): ServerProviderUsageLimits { + return { + source: input.source, + available: false, + reason: input.reason, + windows: [], + checkedAt: input.checkedAt, + }; +} + +export function makeUsageLimitsSnapshot(input: { + readonly source: ServerProviderUsageLimits["source"]; + readonly checkedAt: string; + readonly windows: ReadonlyArray; + readonly unavailableReason: string; +}): ServerProviderUsageLimits { + const normalizedWindows = normalizeUsageWindows(input.windows); + if (normalizedWindows.length === 0) { + return makeUnavailableUsageLimits({ + source: input.source, + checkedAt: input.checkedAt, + reason: input.unavailableReason, + }); + } + + return { + source: input.source, + available: true, + windows: normalizedWindows, + checkedAt: input.checkedAt, + }; +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 43812154649..09318a5da6d 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -11,6 +11,7 @@ import { type DesktopUpdateState, type LocalApi, type ServerConfig, + type ServerProvider, } from "@t3tools/contracts"; import { DateTime } from "effect"; import { page } from "vitest/browser"; @@ -201,6 +202,28 @@ function createBaseServerConfig(): ServerConfig { }; } +function makeProvider( + provider: ServerProvider["provider"], + overrides?: Partial, +): ServerProvider { + return { + provider, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + label: provider === "codex" ? "ChatGPT Pro Subscription" : "Claude Max Subscription", + }, + checkedAt: "2036-04-07T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + }; +} + function makeUtc(value: string) { return DateTime.makeUnsafe(Date.parse(value)); } @@ -453,6 +476,162 @@ describe("GeneralSettingsPanel observability", () => { .toBeInTheDocument(); }); + it("renders two provider usage bars when usage is available", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + makeProvider("codex", { + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2036-04-07T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 42, + resetsAt: "2036-04-07T02:00:00.000Z", + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 65, + resetsAt: "2036-04-10T00:00:00.000Z", + }, + ], + }, + }), + ], + }); + + mounted = await render( + + + , + ); + + await expect.element(page.getByLabelText("Session usage 42%")).toBeInTheDocument(); + await expect.element(page.getByLabelText("Weekly usage 65%")).toBeInTheDocument(); + }); + + it("renders unavailable usage text when quota data is unavailable", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + makeProvider("claudeAgent", { + usageLimits: { + source: "claudeStatusProbe", + available: false, + checkedAt: "2036-04-07T00:00:00.000Z", + reason: "Usage limits unavailable for this Claude account.", + windows: [], + }, + }), + ], + }); + + mounted = await render( + + + , + ); + + await expect + .element(page.getByText("Usage limits unavailable for this Claude account.")) + .toBeInTheDocument(); + }); + + it("hides provider usage UI for disabled or missing providers", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + makeProvider("codex", { + enabled: false, + installed: false, + status: "disabled", + auth: { status: "unknown" }, + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2036-04-07T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 50, + }, + ], + }, + }), + ], + }); + + mounted = await render( + + + , + ); + + await expect.element(page.getByLabelText("Enable Codex")).toBeInTheDocument(); + await expect.element(page.getByLabelText("Session usage 50%")).not.toBeInTheDocument(); + }); + + it("updates provider usage bars when provider snapshots refresh", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + makeProvider("codex", { + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2036-04-07T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 88, + }, + ], + }, + }), + ], + }); + + mounted = await render( + + + , + ); + + const warningBar = page.getByLabelText("Session usage 88%"); + await expect.element(warningBar).toBeInTheDocument(); + await expect.element(warningBar).toHaveClass(/bg-warning/); + + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + makeProvider("codex", { + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2036-04-07T01:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 93, + }, + ], + }, + }), + ], + }); + + const dangerBar = page.getByLabelText("Session usage 93%"); + await expect.element(dangerBar).toBeInTheDocument(); + await expect.element(dangerBar).toHaveClass(/bg-destructive/); + }); + it("creates and shows a pairing link when network access is enabled", async () => { window.desktopBridge = createDesktopBridgeStub({ serverExposureState: { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f76c69581d8..129bf2727af 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -53,7 +53,11 @@ import { selectThreadShellsAcrossEnvironments, useStore, } from "../../store"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { + formatRelativeTime, + formatRelativeTimeLabel, + formatRelativeTimeUntilLabel, +} from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -221,6 +225,109 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } ); } +function getUsageMeterToneClass(usedPercent: number): string { + if (usedPercent >= 90) return "bg-destructive"; + if (usedPercent >= 70) return "bg-warning"; + return "bg-foreground/88"; +} + +function getUsageRemainingLabel(usedPercent: number): string { + const remainingPercent = Math.max(0, Math.min(100, 100 - Math.round(usedPercent))); + return `${remainingPercent}% remaining`; +} + +function getUsageTrackClass(usedPercent: number): string { + if (usedPercent >= 90) return "bg-destructive/12"; + if (usedPercent >= 70) return "bg-warning/12"; + return "bg-white/6 dark:bg-white/6"; +} + +function getUsageResetLabel(resetsAt: string | undefined): string | null { + if (!resetsAt) return null; + const diffMs = new Date(resetsAt).getTime() - Date.now(); + if (!Number.isFinite(diffMs) || diffMs <= 0) { + return null; + } + const dayMs = 24 * 60 * 60 * 1000; + if (diffMs >= dayMs && diffMs < dayMs * 2) { + return "Resets tomorrow"; + } + return `Resets in ${formatRelativeTimeUntilLabel(resetsAt).replace(/ left$/, "")}`; +} + +function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | undefined }) { + useRelativeTimeTick(); + + if (!provider || !provider.enabled || !provider.installed || !provider.usageLimits) { + return null; + } + + if (!provider.usageLimits.available) { + return ( +

+ {provider.usageLimits.reason ?? "Usage limits unavailable for this account"} +

+ ); + } + + return ( +
+ {provider.usageLimits.windows.map((window) => { + const percentageLabel = `${Math.round(window.usedPercent)}%`; + const resetLabel = getUsageResetLabel(window.resetsAt); + const normalizedWidth = Math.max(0, Math.min(100, window.usedPercent)); + const remainingLabel = getUsageRemainingLabel(window.usedPercent); + + return ( +
+
+ + {window.label} limit + + {remainingLabel} +
+
+
+
+ {resetLabel ? ( +

{resetLabel}

+ ) : null} +
+ ); + })} +
+ ); +} + +function ProviderCardShell({ children, expanded }: { children: ReactNode; expanded: boolean }) { + return ( +
+
+ {children} +
+ ); +} + function AboutVersionTitle() { return ( @@ -1112,9 +1219,12 @@ export function GeneralSettingsPanel() { PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; return ( -
+
-
+
+
+ ); })} diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 2603d51d6a9..87b4352f16d 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -23,4 +23,95 @@ describe("ServerProvider", () => { expect(parsed.slashCommands).toEqual([]); expect(parsed.skills).toEqual([]); }); + + it("accepts provider snapshots with usage limits", () => { + const parsed = decodeServerProvider({ + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2026-04-10T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 42, + windowDurationMins: 300, + resetsAt: "2026-04-10T05:00:00.000Z", + }, + ], + }, + }); + + expect(parsed.usageLimits?.available).toBe(true); + expect(parsed.usageLimits?.windows).toHaveLength(1); + }); + + it("accepts unavailable usage limit snapshots", () => { + const parsed = decodeServerProvider({ + provider: "claudeAgent", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + usageLimits: { + source: "claudeStatusProbe", + available: false, + reason: "Usage limits unavailable for this account.", + checkedAt: "2026-04-10T00:00:00.000Z", + windows: [], + }, + }); + + expect(parsed.usageLimits).toEqual({ + source: "claudeStatusProbe", + available: false, + reason: "Usage limits unavailable for this account.", + checkedAt: "2026-04-10T00:00:00.000Z", + windows: [], + }); + }); + + it("rejects invalid usage percentages", () => { + expect(() => + decodeServerProvider({ + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + usageLimits: { + source: "codexAppServer", + available: true, + checkedAt: "2026-04-10T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 101, + }, + ], + }, + }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c08dfa6cd1c..1d67d8e7d23 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -81,6 +81,28 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +const ServerProviderUsagePercent = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)).check( + Schema.isLessThanOrEqualTo(100), +); + +export const ServerProviderUsageWindow = Schema.Struct({ + kind: Schema.Literals(["session", "weekly"]), + label: TrimmedNonEmptyString, + usedPercent: ServerProviderUsagePercent, + resetsAt: Schema.optional(IsoDateTime), + windowDurationMins: Schema.optional(NonNegativeInt), +}); +export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; + +export const ServerProviderUsageLimits = Schema.Struct({ + source: Schema.Literals(["codexAppServer", "claudeStatusProbe"]), + available: Schema.Boolean, + reason: Schema.optional(TrimmedNonEmptyString), + windows: Schema.Array(ServerProviderUsageWindow), + checkedAt: IsoDateTime, +}); +export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; + export const ServerProvider = Schema.Struct({ provider: ProviderKind, enabled: Schema.Boolean, @@ -95,6 +117,7 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + usageLimits: Schema.optional(ServerProviderUsageLimits), }); export type ServerProvider = typeof ServerProvider.Type; From 9eff60959a5c3d8547f1d7377d4fad5578ad0e80 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 17 Apr 2026 11:36:50 +0530 Subject: [PATCH 02/59] feat(provider): add usage limits and retries --- apps/server/src/persistence/Migrations.ts | 8 +++ .../025_AuthAccessManagementCompat.ts | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a348f2ce9d9..461bf2f3339 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -37,8 +37,12 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; +<<<<<<< HEAD import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; +======= +import Migration0025 from "./Migrations/025_AuthAccessManagementCompat.ts"; +>>>>>>> 171df706 (feat(provider): add usage limits and retries) /** * Migration loader with all migrations defined inline. @@ -78,8 +82,12 @@ export const migrationEntries = [ [22, "AuthSessionLastConnectedAt", Migration0022], [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], +<<<<<<< HEAD [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], [26, "AuthAccessManagementCompat", Migration0026], +======= + [25, "AuthAccessManagementCompat", Migration0025], +>>>>>>> 171df706 (feat(provider): add usage limits and retries) ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts b/apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts new file mode 100644 index 00000000000..c2305be6ee5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Compatibility repair for databases where migration ID 20 was already consumed +// before auth access tables were introduced. This recreates the intended schema +// without disturbing databases that already applied the auth migrations normally. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_pairing_links ( + id TEXT PRIMARY KEY, + credential TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + role TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT, + revoked_at TEXT, + label TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active + ON auth_pairing_links(revoked_at, consumed_at, expires_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + role TEXT NOT NULL, + method TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + client_label TEXT, + client_ip_address TEXT, + client_user_agent TEXT, + client_device_type TEXT NOT NULL DEFAULT 'unknown', + client_os TEXT, + client_browser TEXT, + last_connected_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_sessions_active + ON auth_sessions(revoked_at, expires_at, issued_at) + `; +}); From a19efe4a3805d0e66225ce18fea4fc00e6cfa476 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 17 Apr 2026 12:34:05 +0530 Subject: [PATCH 03/59] feat(provider): enhance rate limit handling and telemetry filtering --- .../src/provider/codexAppServer.test.ts | 75 ++++++++ apps/server/src/provider/codexAppServer.ts | 170 +++++++++++++++++- apps/web/src/session-logic.test.ts | 36 ++++ apps/web/src/session-logic.ts | 19 ++ 4 files changed, 295 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/codexAppServer.test.ts b/apps/server/src/provider/codexAppServer.test.ts index 8af14f0ab96..69e6680d156 100644 --- a/apps/server/src/provider/codexAppServer.test.ts +++ b/apps/server/src/provider/codexAppServer.test.ts @@ -87,4 +87,79 @@ describe("codexAppServer", () => { checkedAt: "2026-04-17T00:00:00.000Z", }); }); + + it("accepts alternate rate-limit field names and unix-ms reset timestamps", () => { + const snapshot = readCodexRateLimitsSnapshot({ + limits: [ + { + used_percent: "42", + window_duration_seconds: 18_000, + reset_at: 1_776_384_000_000, + }, + ], + }); + + expect(snapshot).toEqual({ + windows: [ + { + usedPercent: 42, + windowDurationMins: 300, + resetsAt: "2026-04-17T00:00:00.000Z", + }, + ], + }); + }); + + it("falls back to session and weekly windows when duration metadata is missing", () => { + const snapshot = readCodexRateLimitsSnapshot({ + rateLimits: { + shortWindow: { + usedPercent: 21, + resetsAt: 1_776_384_000, + }, + longWindow: { + usedPercent: 67, + resetsAt: 1_776_988_800, + }, + }, + }); + + expect(snapshot).toEqual({ + windows: [ + { + usedPercent: 21, + windowDurationMins: 300, + resetsAt: "2026-04-17T00:00:00.000Z", + }, + { + usedPercent: 67, + windowDurationMins: 10080, + resetsAt: "2026-04-24T00:00:00.000Z", + }, + ], + }); + }); + + it("derives usage percent from used and limit fields", () => { + const snapshot = readCodexRateLimitsSnapshot({ + rateLimits: { + shortWindow: { + used: 42, + limit: 100, + windowDurationMins: 300, + resetsAt: 1_776_384_000, + }, + }, + }); + + expect(snapshot).toEqual({ + windows: [ + { + usedPercent: 42, + windowDurationMins: 300, + resetsAt: "2026-04-17T00:00:00.000Z", + }, + ], + }); + }); }); diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 020e9826737..79a1275bb84 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -54,7 +54,161 @@ function nonEmptyTrimmed(value: unknown): string | undefined { } function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function readUnixSecondsAsIso(value: unknown): string | undefined { + const numeric = readNumber(value); + if (numeric === undefined) { + return undefined; + } + if (numeric > 10_000_000_000) { + const date = new Date(numeric); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; + } + return toIsoDateTimeFromUnixSeconds(numeric); +} + +function readWindowDurationMins(record: Record): number | undefined { + const direct = + readNumber(record.windowDurationMins) ?? + readNumber(record.windowDurationMinutes) ?? + readNumber(record.window_duration_mins) ?? + readNumber(record.window_duration_minutes) ?? + readNumber(record.durationMinutes); + if (direct !== undefined) { + return direct; + } + + const seconds = + readNumber(record.windowDurationSeconds) ?? + readNumber(record.window_duration_seconds) ?? + readNumber(record.durationSeconds); + if (seconds !== undefined) { + return seconds / 60; + } + return undefined; +} + +function readUsedPercent(record: Record): number | undefined { + const direct = + readNumber(record.usedPercent) ?? + readNumber(record.used_percent) ?? + readNumber(record.usagePercent) ?? + readNumber(record.usage_percent) ?? + readNumber(record.percentUsed) ?? + readNumber(record.percent_used); + if (direct !== undefined) { + return direct; + } + + const used = + readNumber(record.used) ?? + readNumber(record.usedTokens) ?? + readNumber(record.used_tokens) ?? + readNumber(record.tokensUsed) ?? + readNumber(record.tokens_used); + const limit = + readNumber(record.limit) ?? + readNumber(record.total) ?? + readNumber(record.max) ?? + readNumber(record.maxTokens) ?? + readNumber(record.max_tokens) ?? + readNumber(record.tokensLimit) ?? + readNumber(record.tokens_limit); + if (used !== undefined && limit !== undefined && limit > 0) { + return (used / limit) * 100; + } + + const remaining = + readNumber(record.remaining) ?? + readNumber(record.remainingTokens) ?? + readNumber(record.remaining_tokens) ?? + readNumber(record.tokensRemaining) ?? + readNumber(record.tokens_remaining); + if (remaining !== undefined && limit !== undefined && limit > 0) { + return ((limit - remaining) / limit) * 100; + } + + return undefined; +} + +function withFallbackCodexWindowDurations( + windows: ReadonlyArray, +): ReadonlyArray { + if (windows.length === 0 || windows.every((window) => window.windowDurationMins !== undefined)) { + return windows; + } + + const SESSION_WINDOW_MINS = 300; + const WEEKLY_WINDOW_MINS = 10_080; + const missingDurationCount = windows.filter( + (window) => window.windowDurationMins === undefined, + ).length; + + if (missingDurationCount === 0) { + return windows; + } + + const knownDurations = windows + .map((window) => window.windowDurationMins) + .filter( + (duration): duration is number => typeof duration === "number" && Number.isFinite(duration), + ); + const knownShortest = knownDurations.length > 0 ? Math.min(...knownDurations) : undefined; + const knownLongest = knownDurations.length > 0 ? Math.max(...knownDurations) : undefined; + const prefersSessionFallback = + knownShortest === undefined || Math.abs(knownShortest - SESSION_WINDOW_MINS) <= 60; + const prefersWeeklyFallback = + knownLongest !== undefined && Math.abs(knownLongest - WEEKLY_WINDOW_MINS) <= 240; + + const byResetTime = [...windows].toSorted((left, right) => { + const leftAt = left.resetsAt ? Date.parse(left.resetsAt) : Number.NaN; + const rightAt = right.resetsAt ? Date.parse(right.resetsAt) : Number.NaN; + if (Number.isNaN(leftAt) && Number.isNaN(rightAt)) return 0; + if (Number.isNaN(leftAt)) return 1; + if (Number.isNaN(rightAt)) return -1; + return leftAt - rightAt; + }); + + const fallbackDurationByWindow = new Map(); + if (byResetTime.length === 1) { + fallbackDurationByWindow.set(byResetTime[0]!, knownShortest ?? SESSION_WINDOW_MINS); + } else { + byResetTime.forEach((window, index) => { + if (index === 0) { + fallbackDurationByWindow.set( + window, + prefersSessionFallback ? SESSION_WINDOW_MINS : (knownShortest ?? SESSION_WINDOW_MINS), + ); + return; + } + if (index === byResetTime.length - 1) { + fallbackDurationByWindow.set( + window, + prefersWeeklyFallback ? WEEKLY_WINDOW_MINS : (knownLongest ?? WEEKLY_WINDOW_MINS), + ); + return; + } + fallbackDurationByWindow.set(window, knownLongest ?? WEEKLY_WINDOW_MINS); + }); + } + + return windows.map((window) => + window.windowDurationMins !== undefined + ? window + : { + ...window, + windowDurationMins: fallbackDurationByWindow.get(window) ?? SESSION_WINDOW_MINS, + }, + ); } function collectCodexRateLimitWindows(value: unknown): ReadonlyArray { @@ -73,10 +227,14 @@ function collectCodexRateLimitWindows(value: unknown): ReadonlyArray 0 ? { windows } : undefined; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index b656dd0f349..a75b19ef16f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1176,6 +1176,42 @@ describe("deriveWorkLogEntries context window handling", () => { expect(entries[0]?.label).toBe("Ran command"); }); + it("excludes account quota telemetry updates from the work log", () => { + const entries = deriveWorkLogEntries( + [ + makeActivity({ + id: "quota-kind-1", + turnId: "turn-1", + kind: "account.rate-limits.updated", + summary: "Account quota updated", + tone: "info", + }), + makeActivity({ + id: "quota-summary-legacy", + turnId: "turn-1", + kind: "tool.updated", + summary: "Account quota updated", + tone: "tool", + payload: { + title: "account quota updated", + }, + }), + makeActivity({ + id: "tool-1", + turnId: "turn-1", + kind: "tool.completed", + summary: "Ran command", + tone: "tool", + }), + ], + TurnId.make("turn-1"), + ); + + expect(entries).toHaveLength(1); + expect(entries[0]?.id).toBe("tool-1"); + expect(entries[0]?.label).toBe("Ran command"); + }); + it("keeps context compaction activities as normal work log entries", () => { const entries = deriveWorkLogEntries( [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a5f611c4104..35d4ff0a48b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -473,6 +473,7 @@ export function deriveWorkLogEntries( .filter((activity) => activity.kind !== "tool.started") .filter((activity) => activity.kind !== "task.started") .filter((activity) => activity.kind !== "context-window.updated") + .filter((activity) => !isHiddenWorkLogTelemetryActivity(activity)) .filter((activity) => activity.summary !== "Checkpoint captured") .filter((activity) => !isPlanBoundaryToolActivity(activity)) .map(toDerivedWorkLogEntry); @@ -481,6 +482,24 @@ export function deriveWorkLogEntries( ); } +function isHiddenWorkLogTelemetryActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind === "account.updated" || activity.kind === "account.rate-limits.updated") { + return true; + } + + const normalizedSummary = activity.summary.trim().toLowerCase(); + if (normalizedSummary === "account quota updated") { + return true; + } + + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const title = typeof payload?.title === "string" ? payload.title.trim().toLowerCase() : ""; + return title === "account quota updated"; +} + function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; From ed12bb859bd4f9d0f152efdca7bf39c8d83db294 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 17 Apr 2026 12:44:22 +0530 Subject: [PATCH 04/59] feat(migrations): rename and reorganize migration files for cleanup and compatibility --- apps/server/src/persistence/Migrations.ts | 10 ++++ .../025_AuthAccessManagementCompat.ts | 53 ------------------- 2 files changed, 10 insertions(+), 53 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 461bf2f3339..360ed12a10a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -38,11 +38,16 @@ import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; <<<<<<< HEAD +<<<<<<< HEAD import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; ======= import Migration0025 from "./Migrations/025_AuthAccessManagementCompat.ts"; >>>>>>> 171df706 (feat(provider): add usage limits and retries) +======= +import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; +import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; +>>>>>>> 036a9b93 (feat(migrations): rename and reorganize migration files for cleanup and compatibility) /** * Migration loader with all migrations defined inline. @@ -82,12 +87,17 @@ export const migrationEntries = [ [22, "AuthSessionLastConnectedAt", Migration0022], [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], +<<<<<<< HEAD <<<<<<< HEAD [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], [26, "AuthAccessManagementCompat", Migration0026], ======= [25, "AuthAccessManagementCompat", Migration0025], >>>>>>> 171df706 (feat(provider): add usage limits and retries) +======= + [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], + [26, "AuthAccessManagementCompat", Migration0026], +>>>>>>> 036a9b93 (feat(migrations): rename and reorganize migration files for cleanup and compatibility) ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts b/apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts deleted file mode 100644 index c2305be6ee5..00000000000 --- a/apps/server/src/persistence/Migrations/025_AuthAccessManagementCompat.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -// Compatibility repair for databases where migration ID 20 was already consumed -// before auth access tables were introduced. This recreates the intended schema -// without disturbing databases that already applied the auth migrations normally. -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE IF NOT EXISTS auth_pairing_links ( - id TEXT PRIMARY KEY, - credential TEXT NOT NULL UNIQUE, - method TEXT NOT NULL, - role TEXT NOT NULL, - subject TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - consumed_at TEXT, - revoked_at TEXT, - label TEXT - ) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active - ON auth_pairing_links(revoked_at, consumed_at, expires_at) - `; - - yield* sql` - CREATE TABLE IF NOT EXISTS auth_sessions ( - session_id TEXT PRIMARY KEY, - subject TEXT NOT NULL, - role TEXT NOT NULL, - method TEXT NOT NULL, - issued_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - revoked_at TEXT, - client_label TEXT, - client_ip_address TEXT, - client_user_agent TEXT, - client_device_type TEXT NOT NULL DEFAULT 'unknown', - client_os TEXT, - client_browser TEXT, - last_connected_at TEXT - ) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_auth_sessions_active - ON auth_sessions(revoked_at, expires_at, issued_at) - `; -}); From caf52c794f21ee1cdd56d654a2309420b6a72c07 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 17 Apr 2026 12:49:24 +0530 Subject: [PATCH 05/59] fix(migrations): remove leftover conflict markers --- apps/server/src/persistence/Migrations.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 360ed12a10a..a348f2ce9d9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -37,17 +37,8 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; -<<<<<<< HEAD -<<<<<<< HEAD import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; -======= -import Migration0025 from "./Migrations/025_AuthAccessManagementCompat.ts"; ->>>>>>> 171df706 (feat(provider): add usage limits and retries) -======= -import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; -import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; ->>>>>>> 036a9b93 (feat(migrations): rename and reorganize migration files for cleanup and compatibility) /** * Migration loader with all migrations defined inline. @@ -87,17 +78,8 @@ export const migrationEntries = [ [22, "AuthSessionLastConnectedAt", Migration0022], [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], -<<<<<<< HEAD -<<<<<<< HEAD - [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], - [26, "AuthAccessManagementCompat", Migration0026], -======= - [25, "AuthAccessManagementCompat", Migration0025], ->>>>>>> 171df706 (feat(provider): add usage limits and retries) -======= [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], [26, "AuthAccessManagementCompat", Migration0026], ->>>>>>> 036a9b93 (feat(migrations): rename and reorganize migration files for cleanup and compatibility) ] as const; export const makeMigrationLoader = (throughId?: number) => From 0199fa354b9639a8da14f5aa2a47d57ec3647e21 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 17 Apr 2026 12:52:48 +0530 Subject: [PATCH 06/59] feat(provider): add timestamp for usage limit checks and improve key generation in settings panel --- apps/server/src/provider/Layers/ClaudeProvider.ts | 3 ++- apps/web/src/components/settings/SettingsPanels.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index afc2522e00a..5ce663a2913 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -820,12 +820,13 @@ export const ClaudeProviderLive = Layer.effect( return next; }); + const checkedAt = new Date().toISOString(); const rawOutput = yield* Effect.tryPromise(() => probeClaudeUsageLimits({ binaryPath, launchArgs, cwd: process.cwd(), - checkedAt: "", + checkedAt, }), ).pipe( Effect.map((result) => result.rawOutput), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 129bf2727af..407a42913b5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -280,7 +280,7 @@ function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | und return (
From 7d83ccad840c1cabf4fb8f4a021c9a54a2ad9931 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 17 Apr 2026 15:42:12 +0530 Subject: [PATCH 07/59] feat(provider): update latestSpawnedChild to async and improve windowKindFromDuration logic --- apps/server/src/provider/claudeUsageProbe.test.ts | 8 ++++---- apps/server/src/provider/providerUsageLimits.ts | 10 +++++++--- apps/web/src/components/settings/SettingsPanels.tsx | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index 6afbf157623..7249b87566b 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -157,7 +157,7 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }); - const child = latestSpawnedChild(); + const child = await latestSpawnedChild(); expect(child.writes).toEqual(["/status\r"]); await vi.advanceTimersByTimeAsync(150); @@ -175,7 +175,7 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }); - const child = latestSpawnedChild(); + const child = await latestSpawnedChild(); child.emitData("Authenticated as Claude Max\n"); await vi.advanceTimersByTimeAsync(150); @@ -193,7 +193,7 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }); - const child = latestSpawnedChild(); + const child = await latestSpawnedChild(); child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); const result = await probePromise; @@ -208,7 +208,7 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }); - const child = latestSpawnedChild(); + const child = await latestSpawnedChild(); await vi.advanceTimersByTimeAsync(150); expect(child.writes).toEqual(["/status\r", "/usage\r"]); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index c14d1b373e3..eacaf7dda91 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -31,12 +31,16 @@ export function windowKindFromDuration(input: { if (typeof duration !== "number" || !Number.isFinite(duration)) { return undefined; } + if ( + duration >= 10080 || + (duration === input.longestWindowDurationMins && + input.longestWindowDurationMins !== input.shortestWindowDurationMins) + ) { + return "weekly"; + } if (duration === input.shortestWindowDurationMins) { return "session"; } - if (duration === input.longestWindowDurationMins) { - return "weekly"; - } return undefined; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 407a42913b5..27c397199ad 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -239,7 +239,7 @@ function getUsageRemainingLabel(usedPercent: number): string { function getUsageTrackClass(usedPercent: number): string { if (usedPercent >= 90) return "bg-destructive/12"; if (usedPercent >= 70) return "bg-warning/12"; - return "bg-white/6 dark:bg-white/6"; + return "bg-black/5 dark:bg-white/6"; } function getUsageResetLabel(resetsAt: string | undefined): string | null { From e5be35c5a8b2edb7fcba8859c664a74fb74d49a3 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 15:44:45 +0530 Subject: [PATCH 08/59] Enhance usage reset label formatting and update styles for provider cards --- .../src/components/settings/SettingsPanels.tsx | 15 ++++++++++----- apps/web/src/timestampFormat.test.ts | 4 ++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5d7eca93623..d9f01d2f772 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -277,7 +277,11 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (diffMs >= dayMs && diffMs < dayMs * 2) { return "Resets tomorrow"; } - return `Resets in ${formatRelativeTimeUntilLabel(resetsAt).replace(/ left$/, "")}`; + const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); + if (relativeLabel === "Soon") { + return "Resets soon"; + } + return `Resets in ${relativeLabel.replace(/ left$/, "")}`; } function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | undefined }) { @@ -305,7 +309,7 @@ function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | und return (
@@ -343,11 +347,12 @@ function ProviderCardShell({ children, expanded }: { children: ReactNode; expand return (
-
+
{children}
); diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c716af92246..ac37e6df120 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -52,6 +52,10 @@ describe("formatRelativeTimeUntilLabel", () => { expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:45.000Z")).toBe("45s left"); }); + it("formats near-future instants as Soon", () => { + expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:03.000Z")).toBe("Soon"); + }); + it("formats minutes remaining", () => { expect(formatRelativeTimeUntilLabel("2026-04-07T12:15:00.000Z")).toBe("15m left"); }); From 1aeaa7a516b46f7d8f76fd7d3e1fe0a35bb617c4 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 16:08:14 +0530 Subject: [PATCH 09/59] Refactor ClaudeProvider and usage probe to enhance error handling and module loading --- apps/server/src/provider/Layers/ClaudeProvider.ts | 2 +- apps/server/src/provider/claudeUsageProbe.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 1dff870a1a3..44a3781eb42 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -904,7 +904,7 @@ export const ClaudeProviderLive = Layer.effect( }); } - if (!entry) { + if (!entry || (entry.inFlight && entry.rawOutput.trim().length === 0)) { return makeUnavailableUsageLimits({ source: "claudeStatusProbe", checkedAt: input.checkedAt, diff --git a/apps/server/src/provider/claudeUsageProbe.ts b/apps/server/src/provider/claudeUsageProbe.ts index 06d596f64a3..061613b27c6 100644 --- a/apps/server/src/provider/claudeUsageProbe.ts +++ b/apps/server/src/provider/claudeUsageProbe.ts @@ -7,7 +7,7 @@ const ANSI_PATTERN = // Matches common CSI / OSC ANSI escape sequences. // eslint-disable-next-line no-control-regex /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g; -const nodePtyModulePromise = import("node-pty"); +let nodePtyModulePromise: Promise | undefined; export interface ClaudeUsageProbeResult { readonly usageLimits: ServerProviderUsageLimits; @@ -31,6 +31,17 @@ function stripAnsi(value: string): string { return value.replaceAll(ANSI_PATTERN, ""); } +async function getNodePtyModule(): Promise { + if (!nodePtyModulePromise) { + nodePtyModulePromise = import("node-pty").catch((error: unknown) => { + nodePtyModulePromise = undefined; + throw error; + }); + } + + return await nodePtyModulePromise; +} + function parsePercent(value: string | undefined): number | undefined { if (!value) return undefined; const parsed = Number.parseFloat(value); @@ -171,7 +182,7 @@ export async function probeClaudeUsageLimits(input: { readonly cwd: string; readonly checkedAt: string; }): Promise { - const nodePty = await nodePtyModulePromise; + const nodePty = await getNodePtyModule(); const probeArgs = [ ...(input.launchArgs?.trim().split(/\s+/).filter(Boolean) ?? []), "--permission-mode", From c239261bfd809cb7fa53cc13f994da5a6597bf77 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 19:56:37 +0530 Subject: [PATCH 10/59] Refactor ClaudeProvider to improve variable naming and enhance clarity in usage probe logic --- apps/server/src/provider/Layers/ClaudeProvider.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 44a3781eb42..397bd99e19e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -893,10 +893,11 @@ export const ClaudeProviderLive = Layer.effect( (input) => Effect.gen(function* () { const key = JSON.stringify([input.binaryPath, input.launchArgs]); - const entry = (yield* Ref.get(usageProbeStateRef)).get(key); - const isFresh = entry !== undefined && Date.now() - entry.fetchedAtMs < usageProbeTtlMs; + const currentEntry = (yield* Ref.get(usageProbeStateRef)).get(key); + const isFresh = + currentEntry !== undefined && Date.now() - currentEntry.fetchedAtMs < usageProbeTtlMs; - if ((!entry || !isFresh) && !entry?.inFlight) { + if ((!currentEntry || !isFresh) && !currentEntry?.inFlight) { yield* Effect.sync(() => { void Effect.runPromiseExit( refreshUsageProbe(key, input.binaryPath, input.launchArgs), @@ -904,7 +905,9 @@ export const ClaudeProviderLive = Layer.effect( }); } - if (!entry || (entry.inFlight && entry.rawOutput.trim().length === 0)) { + const latestEntry = (yield* Ref.get(usageProbeStateRef)).get(key); + + if (!latestEntry || (latestEntry.inFlight && latestEntry.rawOutput.trim().length === 0)) { return makeUnavailableUsageLimits({ source: "claudeStatusProbe", checkedAt: input.checkedAt, @@ -913,7 +916,7 @@ export const ClaudeProviderLive = Layer.effect( } return parseClaudeUsageLimitsOutput({ - output: entry.rawOutput, + output: latestEntry.rawOutput, checkedAt: input.checkedAt, }); }), From e82c5fc4197ca7d13d2b1b26e18fa4aabd91ddbf Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 21:04:29 +0530 Subject: [PATCH 11/59] feat: add provider usage limits schema and server-side persistence --- .../src/provider/claudeUsageProbe.test.ts | 14 +++--- .../src/provider/providerUsageLimits.ts | 4 +- apps/server/src/server.ts | 12 ++++-- packages/contracts/src/server.test.ts | 43 +++++++++++++++++++ packages/contracts/src/server.ts | 2 +- 5 files changed, 63 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index 7249b87566b..e35f3cb02d4 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -58,12 +58,14 @@ import { shouldRequestClaudeUsageFallback, } from "./claudeUsageProbe.ts"; -function latestSpawnedChild(): MockPtyChild { - const latest = spawnMock.mock.results.at(-1)?.value; - if (!latest) { - throw new Error("Expected node-pty spawn to be called."); - } - return latest; +async function latestSpawnedChild(): Promise { + // Wait for dynamic import to resolve and spawn to be called + await vi.waitFor(() => { + if (!spawnMock.mock.results.at(-1)?.value) { + throw new Error("Expected node-pty spawn to be called."); + } + }); + return spawnMock.mock.results.at(-1)?.value!; } describe("claudeUsageProbe", () => { diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index eacaf7dda91..7582fdf741f 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -90,12 +90,12 @@ export function normalizeUsageWindows( export function makeUnavailableUsageLimits(input: { readonly source: ServerProviderUsageLimits["source"]; readonly checkedAt: string; - readonly reason: string; + readonly reason?: string; }): ServerProviderUsageLimits { return { source: input.source, available: false, - reason: input.reason, + reason: input.reason ?? "Unable to fetch usage", windows: [], checkedAt: input.checkedAt, }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9aee0b987fb..a722f8bb934 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts"; import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderUsageStateLive } from "./provider/Layers/ProviderUsageState.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; @@ -218,9 +219,14 @@ const AuthLayerLive = ServerAuthLive.pipe( Layer.provide(ServerSecretStoreLive), ); -const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( - Layer.provideMerge(ProviderLayerLive), - Layer.provideMerge(OrchestrationLayerLive), +const ProviderRuntimeLayerLive = Layer.mergeAll( + ProviderLayerLive, + ProviderUsageStateLive.pipe(Layer.provide(ProviderLayerLive)), + ProviderSessionReaperLive.pipe( + Layer.provideMerge(OrchestrationLayerLive), + Layer.provide(ProviderLayerLive), + ), + OrchestrationLayerLive, ); const RuntimeDependenciesLive = ReactorLayerLive.pipe( diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 87b4352f16d..b546efcb660 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -86,6 +86,49 @@ describe("ServerProvider", () => { }); }); + it("accepts cursor and opencode usage limit sources", () => { + const cursorParsed = decodeServerProvider({ + provider: "cursor", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + usageLimits: { + source: "cursorAcp", + available: true, + checkedAt: "2026-04-10T00:00:00.000Z", + windows: [{ kind: "session", label: "Session", usedPercent: 12 }], + }, + }); + const openCodeParsed = decodeServerProvider({ + provider: "opencode", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + usageLimits: { + source: "opencodeManaged", + available: false, + reason: "Unable to fetch usage", + checkedAt: "2026-04-10T00:00:00.000Z", + windows: [], + }, + }); + + expect(cursorParsed.usageLimits?.source).toBe("cursorAcp"); + expect(openCodeParsed.usageLimits?.source).toBe("opencodeManaged"); + }); + it("rejects invalid usage percentages", () => { expect(() => decodeServerProvider({ diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1d67d8e7d23..6d1685e08c2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -95,7 +95,7 @@ export const ServerProviderUsageWindow = Schema.Struct({ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; export const ServerProviderUsageLimits = Schema.Struct({ - source: Schema.Literals(["codexAppServer", "claudeStatusProbe"]), + source: Schema.Literals(["codexAppServer", "claudeStatusProbe", "cursorAcp", "opencodeManaged"]), available: Schema.Boolean, reason: Schema.optional(TrimmedNonEmptyString), windows: Schema.Array(ServerProviderUsageWindow), From 178e4622ba83b527983db5bdc4720c66db7021e5 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 21:04:35 +0530 Subject: [PATCH 12/59] feat: implement OpenCode usage limits probing and display filtering --- .../Layers/ProviderUsageState.test.ts | 87 ++++++++++++ .../src/provider/Layers/ProviderUsageState.ts | 86 ++++++++++++ .../provider/Services/ProviderUsageState.ts | 17 +++ .../src/provider/openCodeUsageLimits.test.ts | 101 ++++++++++++++ .../src/provider/openCodeUsageLimits.ts | 131 ++++++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 33 +++++ .../runtimeUsageToProviderUsageLimits.test.ts | 32 +++++ .../runtimeUsageToProviderUsageLimits.ts | 38 +++++ 8 files changed, 525 insertions(+) create mode 100644 apps/server/src/provider/Layers/ProviderUsageState.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderUsageState.ts create mode 100644 apps/server/src/provider/Services/ProviderUsageState.ts create mode 100644 apps/server/src/provider/openCodeUsageLimits.test.ts create mode 100644 apps/server/src/provider/openCodeUsageLimits.ts create mode 100644 apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts create mode 100644 apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts diff --git a/apps/server/src/provider/Layers/ProviderUsageState.test.ts b/apps/server/src/provider/Layers/ProviderUsageState.test.ts new file mode 100644 index 00000000000..7d211980455 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderUsageState.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { Effect, Layer, PubSub, Stream } from "effect"; +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; + +import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; +import { ProviderUsageStateLive } from "./ProviderUsageState.ts"; + +function makeProviderServiceStub() { + const pubsub = Effect.runSync(PubSub.unbounded()); + + return { + pubsub, + layer: Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.fromPubSub(pubsub), + }), + }; +} + +describe("ProviderUsageStateLive", () => { + it("sets, gets, and clears usage by provider", async () => { + const stub = makeProviderServiceStub(); + const result = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* usageState.set("cursor", { + source: "cursorAcp", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "Session", usedPercent: 25 }], + }); + const first = yield* usageState.get("cursor"); + yield* usageState.clear("cursor"); + const second = yield* usageState.get("cursor"); + + return { first, second }; + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(result.first?.windows).toEqual([{ kind: "session", label: "Session", usedPercent: 25 }]); + expect(result.second).toBeUndefined(); + }); + + it("ingests real Cursor token usage events and isolates providers", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-1" as never, + provider: "cursor", + threadId: "thread-1" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + usage: { + usedTokens: 50, + maxTokens: 100, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + + return { + cursor: yield* usageState.get("cursor"), + opencode: yield* usageState.get("opencode"), + }; + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state.cursor?.windows).toEqual([{ kind: "session", label: "Session", usedPercent: 50 }]); + expect(state.opencode).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.ts b/apps/server/src/provider/Layers/ProviderUsageState.ts new file mode 100644 index 00000000000..8aaa13aac67 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderUsageState.ts @@ -0,0 +1,86 @@ +import type { + ProviderKind, + ProviderRuntimeEvent, + ServerProviderUsageLimits, +} from "@t3tools/contracts"; +import { Effect, Layer, Ref, Stream } from "effect"; + +import { runtimeUsageToProviderUsageLimits } from "../runtimeUsageToProviderUsageLimits.ts"; +import { + ProviderUsageState, + type ProviderUsageStateShape, +} from "../Services/ProviderUsageState.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; + +function toCursorUsageLimits( + event: Extract, +) { + const maxTokens = event.payload.usage.maxTokens; + if (typeof maxTokens !== "number") { + return undefined; + } + + return runtimeUsageToProviderUsageLimits({ + source: "cursorAcp", + checkedAt: event.createdAt, + usedTokens: event.payload.usage.usedTokens, + maxTokens, + }); +} + +export const ProviderUsageStateLive = Layer.effect( + ProviderUsageState, + Effect.gen(function* () { + const providerService = yield* ProviderService; + const stateRef = yield* Ref.make(new Map()); + + const service: ProviderUsageStateShape = { + get: (provider) => Ref.get(stateRef).pipe(Effect.map((state) => state.get(provider))), + set: (provider, usage) => + Ref.update(stateRef, (state) => { + const next = new Map(state); + if (usage === undefined) { + next.delete(provider); + } else { + next.set(provider, usage); + } + return next; + }), + clear: (provider) => + Ref.update(stateRef, (state) => { + if (!state.has(provider)) { + return state; + } + const next = new Map(state); + next.delete(provider); + return next; + }), + }; + + yield* Stream.runForEach(providerService.streamEvents, (event) => + Effect.gen(function* () { + if (event.provider !== "cursor") { + return; + } + + if (event.type === "session.started" || event.type === "session.exited") { + yield* service.clear("cursor"); + return; + } + + if (event.type !== "thread.token-usage.updated") { + return; + } + + const usage = toCursorUsageLimits(event); + if (usage === undefined) { + return; + } + + yield* service.set("cursor", usage); + }), + ).pipe(Effect.forkScoped); + + return service; + }), +); diff --git a/apps/server/src/provider/Services/ProviderUsageState.ts b/apps/server/src/provider/Services/ProviderUsageState.ts new file mode 100644 index 00000000000..7b9d828d1c2 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderUsageState.ts @@ -0,0 +1,17 @@ +import type { ProviderKind, ServerProviderUsageLimits } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface ProviderUsageStateShape { + readonly get: (provider: ProviderKind) => Effect.Effect; + readonly set: ( + provider: ProviderKind, + usage: ServerProviderUsageLimits | undefined, + ) => Effect.Effect; + readonly clear: (provider: ProviderKind) => Effect.Effect; +} + +export class ProviderUsageState extends Context.Service< + ProviderUsageState, + ProviderUsageStateShape +>()("t3/provider/Services/ProviderUsageState") {} diff --git a/apps/server/src/provider/openCodeUsageLimits.test.ts b/apps/server/src/provider/openCodeUsageLimits.test.ts new file mode 100644 index 00000000000..445ed6a4fb7 --- /dev/null +++ b/apps/server/src/provider/openCodeUsageLimits.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOpenCodeManagedUsageLimits } from "./openCodeUsageLimits.ts"; + +describe("resolveOpenCodeManagedUsageLimits", () => { + it("returns one managed usage window for opencode-go", () => { + expect( + resolveOpenCodeManagedUsageLimits({ + checkedAt: "2026-04-18T00:00:00.000Z", + inventory: { + providerList: { + connected: ["opencode-go"], + default: {}, + all: [ + { + id: "opencode-go", + name: "OpenCode Go", + env: [], + models: {}, + usage: { + usedPercent: 32, + }, + }, + ], + }, + agents: [], + } as never, + }), + ).toEqual({ + source: "opencodeManaged", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "OpenCode Go", usedPercent: 32 }], + }); + }); + + it("returns both managed usage windows when both subscriptions expose real usage", () => { + const result = resolveOpenCodeManagedUsageLimits({ + checkedAt: "2026-04-18T00:00:00.000Z", + inventory: { + providerList: { + connected: ["opencode-go", "opencode-zen"], + default: {}, + all: [ + { + id: "opencode-go", + name: "OpenCode Go", + env: [], + models: {}, + usage: { usedPercent: 10 }, + }, + { + id: "opencode-zen", + name: "OpenCode Zen", + env: [], + models: {}, + usage: { used: 45, limit: 90 }, + }, + ], + }, + agents: [], + } as never, + }); + + expect(result?.windows).toEqual([ + { kind: "session", label: "OpenCode Go", usedPercent: 10 }, + { kind: "session", label: "OpenCode Zen", usedPercent: 50 }, + ]); + }); + + it("ignores non-managed and malformed usage sources", () => { + expect( + resolveOpenCodeManagedUsageLimits({ + checkedAt: "2026-04-18T00:00:00.000Z", + inventory: { + providerList: { + connected: ["anthropic", "opencode-go"], + default: {}, + all: [ + { + id: "anthropic", + name: "Anthropic", + env: [], + models: {}, + usage: { usedPercent: 99 }, + }, + { + id: "opencode-go", + name: "OpenCode Go", + env: [], + models: {}, + usage: { used: 10, limit: 0 }, + }, + ], + }, + agents: [], + } as never, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/openCodeUsageLimits.ts b/apps/server/src/provider/openCodeUsageLimits.ts new file mode 100644 index 00000000000..eacefb60ef7 --- /dev/null +++ b/apps/server/src/provider/openCodeUsageLimits.ts @@ -0,0 +1,131 @@ +import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; + +import type { OpenCodeInventory } from "./opencodeRuntime.ts"; +import { getOpenCodeManagedProviderDescriptor } from "./opencodeRuntime.ts"; +import { clampPercent } from "./providerUsageLimits.ts"; + +type ManagedProviderRecord = OpenCodeInventory["providerList"]["all"][number]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readIsoDateTime(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const date = new Date(trimmed); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; +} + +function toUsageWindow( + usage: Record, + label: string, +): ServerProviderUsageWindow | undefined { + const resetsAt = + readIsoDateTime(usage.resetsAt) ?? + readIsoDateTime(usage.resetAt) ?? + readIsoDateTime(usage.renewsAt); + const explicitPercent = + readFiniteNumber(usage.usedPercent) ?? + readFiniteNumber(usage.usagePercent) ?? + readFiniteNumber(usage.percentUsed) ?? + readFiniteNumber(usage.percentage); + + const computedPercent = + explicitPercent ?? + (() => { + const used = readFiniteNumber(usage.used); + const limit = + readFiniteNumber(usage.limit) ?? + readFiniteNumber(usage.max) ?? + readFiniteNumber(usage.total); + if (used === undefined || limit === undefined || limit <= 0) { + return undefined; + } + return (used / limit) * 100; + })(); + + if (computedPercent === undefined || !Number.isFinite(computedPercent)) { + return undefined; + } + + return { + kind: "session", + label, + usedPercent: clampPercent(computedPercent), + ...(resetsAt ? { resetsAt } : {}), + }; +} + +function extractUsageWindow( + provider: ManagedProviderRecord, +): ServerProviderUsageWindow | undefined { + const descriptor = getOpenCodeManagedProviderDescriptor(provider.id); + if (!descriptor) { + return undefined; + } + + const providerRecord = provider as unknown as Record; + const providerOptions = isRecord(providerRecord.options) ? providerRecord.options : undefined; + const providerMetadata = isRecord(providerRecord.metadata) ? providerRecord.metadata : undefined; + const candidates = [ + providerRecord.usage, + providerRecord.quota, + providerRecord.subscriptionUsage, + providerRecord.usageLimits, + providerOptions?.usage, + providerOptions?.quota, + providerOptions?.subscriptionUsage, + providerOptions?.usageLimits, + providerMetadata?.usage, + providerMetadata?.quota, + ]; + + for (const candidate of candidates) { + if (!isRecord(candidate)) { + continue; + } + const window = toUsageWindow(candidate, descriptor.label); + if (window) { + return window; + } + } + + return undefined; +} + +export function resolveOpenCodeManagedUsageLimits(input: { + readonly checkedAt: string; + readonly inventory: OpenCodeInventory; +}): ServerProviderUsageLimits | undefined { + const connected = new Set(input.inventory.providerList.connected); + const windows = input.inventory.providerList.all + .filter((provider) => connected.has(provider.id)) + .flatMap((provider) => { + if (!getOpenCodeManagedProviderDescriptor(provider.id)) { + return []; + } + const window = extractUsageWindow(provider); + return window ? [window] : []; + }); + + if (windows.length === 0) { + return undefined; + } + + return { + source: "opencodeManaged", + available: true, + checkedAt: input.checkedAt, + windows, + }; +} diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 4778f6eac91..494118603b4 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -67,6 +67,33 @@ export interface OpenCodeInventory { readonly agents: ReadonlyArray; } +export interface OpenCodeManagedProviderDescriptor { + readonly providerId: "opencode-go" | "opencode-zen"; + readonly label: "OpenCode Go" | "OpenCode Zen"; +} + +const OPENCODE_MANAGED_PROVIDER_DESCRIPTORS = { + "opencode-go": { + providerId: "opencode-go", + label: "OpenCode Go", + }, + "opencode-zen": { + providerId: "opencode-zen", + label: "OpenCode Zen", + }, +} satisfies Record; + +export function getOpenCodeManagedProviderDescriptor( + providerId: string, +): OpenCodeManagedProviderDescriptor | undefined { + if (!(providerId in OPENCODE_MANAGED_PROVIDER_DESCRIPTORS)) { + return undefined; + } + return OPENCODE_MANAGED_PROVIDER_DESCRIPTORS[ + providerId as keyof typeof OPENCODE_MANAGED_PROVIDER_DESCRIPTORS + ]; +} + export interface ParsedOpenCodeModelSlug { readonly providerID: string; readonly modelID: string; @@ -548,12 +575,18 @@ export function flattenOpenCodeModels( input: OpenCodeInventory, ): ReadonlyArray { const connected = new Set(input.providerList.connected); + const officialProviderIds = new Set( + Object.keys(OPENCODE_MANAGED_PROVIDER_DESCRIPTORS), + ); const models: Array = []; for (const provider of input.providerList.all) { if (!connected.has(provider.id)) { continue; } + if (!officialProviderIds.has(provider.id)) { + continue; + } for (const model of Object.values(provider.models)) { models.push({ diff --git a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts new file mode 100644 index 00000000000..4d8c40184f2 --- /dev/null +++ b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { runtimeUsageToProviderUsageLimits } from "./runtimeUsageToProviderUsageLimits.ts"; + +describe("runtimeUsageToProviderUsageLimits", () => { + it("maps real token usage into a single session window", () => { + expect( + runtimeUsageToProviderUsageLimits({ + source: "cursorAcp", + checkedAt: "2026-04-18T00:00:00.000Z", + usedTokens: 75, + maxTokens: 100, + }), + ).toEqual({ + source: "cursorAcp", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "Session", usedPercent: 75 }], + }); + }); + + it("returns undefined for invalid token limits", () => { + expect( + runtimeUsageToProviderUsageLimits({ + source: "cursorAcp", + checkedAt: "2026-04-18T00:00:00.000Z", + usedTokens: 75, + maxTokens: 0, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts new file mode 100644 index 00000000000..ed84e32e26a --- /dev/null +++ b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts @@ -0,0 +1,38 @@ +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; + +import { clampPercent } from "./providerUsageLimits.ts"; + +export function runtimeUsageToProviderUsageLimits(input: { + readonly source: "cursorAcp" | "opencodeManaged"; + readonly checkedAt: string; + readonly usedTokens: number; + readonly maxTokens: number; + readonly label?: string; +}): ServerProviderUsageLimits | undefined { + if ( + !Number.isFinite(input.usedTokens) || + !Number.isFinite(input.maxTokens) || + input.usedTokens < 0 || + input.maxTokens <= 0 + ) { + return undefined; + } + + const rawPercent = (input.usedTokens / input.maxTokens) * 100; + if (!Number.isFinite(rawPercent)) { + return undefined; + } + + return { + source: input.source, + available: true, + checkedAt: input.checkedAt, + windows: [ + { + kind: "session", + label: input.label?.trim() || "Session", + usedPercent: clampPercent(rawPercent), + }, + ], + }; +} From 6cec3e5573c894e714a7bfaaab8f1eed5b794ab4 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 21:04:39 +0530 Subject: [PATCH 13/59] feat: add usage limits support to Cursor provider --- .../src/provider/Layers/CursorAdapter.ts | 20 +++++++++ .../provider/Layers/CursorProvider.test.ts | 28 ++++++++++++ .../src/provider/Layers/CursorProvider.ts | 44 +++++++++++++++++-- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index b09e0356bfb..5da3b8a6768 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -56,6 +56,7 @@ import { makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent, + makeAcpUsageUpdatedEvent, } from "../acp/AcpCoreRuntimeEvents.ts"; import { type AcpSessionMode, @@ -775,6 +776,25 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }), ); return; + case "UsageUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.payload.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpUsageUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + size: event.payload.size, + used: event.payload.used, + rawPayload: event.payload.rawPayload, + }), + ); + return; } }), ), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index be90e3c8569..5c3acf5575c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -588,6 +588,34 @@ describe("parseCursorAboutOutput", () => { }); }); +describe("buildCursorProviderSnapshot", () => { + it("attaches real Cursor ACP usage when provided", () => { + const snapshot = buildCursorProviderSnapshot({ + checkedAt: "2026-04-18T00:00:00.000Z", + cursorSettings: { + enabled: true, + binaryPath: "agent", + apiEndpoint: "", + customModels: [], + } satisfies CursorSettings, + parsed: { + version: "2026.04.18-123456", + status: "ready", + auth: { status: "authenticated" }, + }, + usageLimits: { + source: "cursorAcp", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "Session", usedPercent: 50 }], + }, + }); + + expect(snapshot.usageLimits?.source).toBe("cursorAcp"); + expect(snapshot.usageLimits?.available).toBe(true); + }); +}); + describe("Cursor parameterized model picker preview gating", () => { it("parses Cursor CLI version dates from build versions", () => { expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 70d5656b3ec..df5d732f44f 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -25,8 +25,10 @@ import { } from "../providerSnapshot.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { CursorProvider } from "../Services/CursorProvider.ts"; +import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; const PROVIDER = "cursor" as const; const EMPTY_CAPABILITIES: ModelCapabilities = { @@ -37,9 +39,9 @@ const EMPTY_CAPABILITIES: ModelCapabilities = { promptInjectedEffortLevels: [], }; -const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; -const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; -const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; +const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 60_000; +const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "15 seconds"; +const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 2; const CURSOR_REFRESH_INTERVAL = "1 hour"; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { @@ -51,6 +53,11 @@ export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider { const checkedAt = new Date().toISOString(); const models = getCursorFallbackModels(cursorSettings); + const unavailableUsageLimits = makeUnavailableUsageLimits({ + source: "cursorAcp", + checkedAt, + reason: "Unable to fetch usage", + }); if (!cursorSettings.enabled) { return buildServerProvider({ @@ -64,6 +71,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser status: "warning", auth: { status: "unknown" }, message: "Cursor is disabled in T3 Code settings.", + usageLimits: unavailableUsageLimits, }, }); } @@ -79,6 +87,7 @@ function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): Ser status: "warning", auth: { status: "unknown" }, message: "Checking Cursor Agent availability...", + usageLimits: unavailableUsageLimits, }, }); } @@ -606,6 +615,7 @@ export function buildCursorProviderSnapshot(input: { readonly parsed: CursorAboutResult; readonly discoveredModels?: ReadonlyArray; readonly discoveryWarning?: string; + readonly usageLimits?: ServerProvider["usageLimits"]; }): ServerProvider { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); return buildServerProvider({ @@ -625,6 +635,7 @@ export function buildCursorProviderSnapshot(input: { input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, auth: input.parsed.auth, ...(message ? { message } : {}), + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), }, }); } @@ -1079,10 +1090,37 @@ export const CursorProviderLive = Layer.effect( Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const providerUsageState = yield* Effect.serviceOption(ProviderUsageState); const checkProvider = checkCursorProviderStatus().pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.flatMap((snapshot) => + Option.match(providerUsageState, { + onNone: () => + Effect.succeed({ + ...snapshot, + usageLimits: makeUnavailableUsageLimits({ + source: "cursorAcp", + checkedAt: snapshot.checkedAt, + reason: "Unable to fetch usage", + }), + }), + onSome: (usageState) => + usageState.get(PROVIDER).pipe( + Effect.map((usageLimits) => ({ + ...snapshot, + usageLimits: + usageLimits ?? + makeUnavailableUsageLimits({ + source: "cursorAcp", + checkedAt: snapshot.checkedAt, + reason: "Unable to fetch usage", + }), + })), + ), + }), + ), ); return yield* makeManagedServerProvider({ From 0bd668e91fe893c414b22e70e63394a8bef4bb27 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 21:04:44 +0530 Subject: [PATCH 14/59] feat: add usage limits to OpenCode provider and ACP runtime model --- .../provider/Layers/OpenCodeProvider.test.ts | 91 ++++++++++++++++--- .../src/provider/Layers/OpenCodeProvider.ts | 46 +++++++++- .../provider/acp/AcpCoreRuntimeEvents.test.ts | 28 ++++++ .../src/provider/acp/AcpCoreRuntimeEvents.ts | 29 ++++++ .../src/provider/acp/AcpRuntimeModel.test.ts | 40 ++++++++ .../src/provider/acp/AcpRuntimeModel.ts | 21 +++++ 6 files changed, 235 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index cf3d588d9db..fc49f3cbc5d 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -10,20 +10,31 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts"; -const runtimeMock = vi.hoisted(() => { - const state = { +const runtimeMock = { + state: { runVersionError: null as Error | null, inventoryError: null as Error | null, - }; - - return { - state, - reset() { - state.runVersionError = null; - state.inventoryError = null; + inventoryResult: { + providerList: { connected: [], default: {}, all: [] }, + agents: [], + } as { + providerList: { + connected: string[]; + default: Record; + all: Array>; + }; + agents: Array; }, - }; -}); + }, + reset() { + this.state.runVersionError = null; + this.state.inventoryError = null; + this.state.inventoryResult = { + providerList: { connected: [], default: {}, all: [] }, + agents: [], + }; + }, +}; vi.mock("../opencodeRuntime.ts", async () => { const actual = @@ -48,10 +59,7 @@ vi.mock("../opencodeRuntime.ts", async () => { if (runtimeMock.state.inventoryError) { throw runtimeMock.state.inventoryError; } - return { - providerList: { connected: [], all: [] }, - agents: [], - }; + return runtimeMock.state.inventoryResult; }), flattenOpenCodeModels: vi.fn(() => []), }; @@ -92,6 +100,59 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); }), ); + + it.effect("shows managed OpenCode usage only when a managed provider reports real usage", () => + Effect.gen(function* () { + runtimeMock.state.inventoryResult = { + providerList: { + connected: ["opencode-go", "anthropic"], + default: {}, + all: [ + { + id: "opencode-go", + name: "OpenCode Go", + env: [], + models: {}, + usage: { usedPercent: 27 }, + }, + { + id: "anthropic", + name: "Anthropic", + env: [], + models: {}, + usage: { usedPercent: 99 }, + }, + ], + }, + agents: [], + }; + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.usageLimits?.available, true); + assert.deepEqual(snapshot.usageLimits?.windows, [ + { kind: "session", label: "OpenCode Go", usedPercent: 27 }, + ]); + }), + ); + + it.effect("shows unavailable usage when only upstream providers are connected", () => + Effect.gen(function* () { + runtimeMock.state.inventoryResult = { + providerList: { + connected: ["anthropic"], + default: {}, + all: [{ id: "anthropic", name: "Anthropic", env: [], models: {} }], + }, + agents: [], + }; + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.usageLimits?.available, false); + assert.equal(snapshot.usageLimits?.reason, "Unable to fetch usage"); + }), + ); }); it.layer( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index f1969412572..cb27187845e 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -16,9 +16,12 @@ import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, createOpenCodeSdkClient, flattenOpenCodeModels, + getOpenCodeManagedProviderDescriptor, loadOpenCodeInventory, runOpenCodeCommand, } from "../opencodeRuntime.ts"; +import { resolveOpenCodeManagedUsageLimits } from "../openCodeUsageLimits.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; const PROVIDER = "opencode" as const; @@ -135,6 +138,11 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server openCodeSettings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); + const usageLimits = makeUnavailableUsageLimits({ + source: "opencodeManaged", + checkedAt, + reason: "Unable to fetch usage", + }); if (!openCodeSettings.enabled) { return buildServerProvider({ @@ -151,6 +159,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server openCodeSettings.serverUrl.trim().length > 0 ? "OpenCode is disabled in T3 Code settings. A server URL is configured." : "OpenCode is disabled in T3 Code settings.", + usageLimits, }, }); } @@ -166,6 +175,7 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server status: "warning", auth: { status: "unknown" }, message: "OpenCode provider status has not been checked in this session yet.", + usageLimits, }, }); }; @@ -200,6 +210,11 @@ export function checkOpenCodeProviderStatus(input: { status: "error", auth: { status: "unknown" }, message: failure.message, + usageLimits: makeUnavailableUsageLimits({ + source: "opencodeManaged", + checkedAt, + reason: "Unable to fetch usage", + }), }, }); }; @@ -224,6 +239,11 @@ export function checkOpenCodeProviderStatus(input: { message: isExternalServer ? "OpenCode is disabled in T3 Code settings. A server URL is configured." : "OpenCode is disabled in T3 Code settings.", + usageLimits: makeUnavailableUsageLimits({ + source: "opencodeManaged", + checkedAt, + reason: "Unable to fetch usage", + }), }, }); } @@ -283,7 +303,20 @@ export function checkOpenCodeProviderStatus(input: { customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); + const usageLimits = + resolveOpenCodeManagedUsageLimits({ + checkedAt, + inventory: inventoryExit.value, + }) ?? + makeUnavailableUsageLimits({ + source: "opencodeManaged", + checkedAt, + reason: "Unable to fetch usage", + }); const connectedCount = inventoryExit.value.providerList.connected.length; + const connectedManagedCount = inventoryExit.value.providerList.connected.filter( + (providerId) => getOpenCodeManagedProviderDescriptor(providerId) !== undefined, + ).length; return buildServerProvider({ provider: PROVIDER, enabled: true, @@ -297,12 +330,15 @@ export function checkOpenCodeProviderStatus(input: { status: connectedCount > 0 ? "authenticated" : "unknown", type: "opencode", }, + usageLimits, message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", + connectedManagedCount > 0 + ? `${connectedManagedCount} OpenCode-managed provider${connectedManagedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", }, }); }); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index 79b51f585b1..b7e372723e9 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -8,6 +8,7 @@ import { makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent, + makeAcpUsageUpdatedEvent, } from "./AcpCoreRuntimeEvents.ts"; describe("AcpCoreRuntimeEvents", () => { @@ -152,4 +153,31 @@ describe("AcpCoreRuntimeEvents", () => { }, }); }); + + it("maps ACP usage updates to canonical thread token usage events", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + + expect( + makeAcpUsageUpdatedEvent({ + stamp, + provider: "cursor", + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + size: 200_000, + used: 50_000, + rawPayload: { sessionId: "session-1", update: { sessionUpdate: "usage_update" } }, + }), + ).toMatchObject({ + type: "thread.token-usage.updated", + payload: { + usage: { + usedTokens: 50_000, + maxTokens: 200_000, + }, + }, + raw: { + payload: { sessionId: "session-1", update: { sessionUpdate: "usage_update" } }, + }, + }); + }); }); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index 0c0f06cc622..c17a8ce4156 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -240,3 +240,32 @@ export function makeAcpContentDeltaEvent(input: { }, }; } + +export function makeAcpUsageUpdatedEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly size: number; + readonly used: number; + readonly rawPayload: unknown; +}): ProviderRuntimeEvent { + return { + type: "thread.token-usage.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: { + usage: { + usedTokens: Math.max(0, Math.round(input.used)), + maxTokens: Math.max(1, Math.round(input.size)), + }, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: input.rawPayload, + }, + }; +} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index ae12d3112aa..905c978a3ad 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -245,6 +245,46 @@ describe("AcpRuntimeModel", () => { ]); }); + it("parses ACP usage updates and ignores malformed values", () => { + const validResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + size: 200_000, + used: 50_000, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(validResult.events).toEqual([ + { + _tag: "UsageUpdated", + payload: { + size: 200_000, + used: 50_000, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + size: 200_000, + used: 50_000, + }, + }, + }, + }, + ]); + + const invalidResult = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + size: 0, + used: 50_000, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(invalidResult.events).toEqual([]); + }); + it("keeps permission request parsing compatible with loose extension payloads", () => { const request = parsePermissionRequest({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index ffd214a5bf1..5fda15a1877 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -69,6 +69,14 @@ export type AcpParsedSessionEvent = readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly payload: { + readonly size: number; + readonly used: number; + readonly rawPayload: unknown; + }; }; type AcpSessionSetupResponse = @@ -474,6 +482,19 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } + case "usage_update": { + if (Number.isFinite(upd.size) && Number.isFinite(upd.used) && upd.size > 0 && upd.used >= 0) { + events.push({ + _tag: "UsageUpdated", + payload: { + size: upd.size, + used: upd.used, + rawPayload: params, + }, + }); + } + break; + } default: break; } From 499d730e5749445509a32231dee4eb26a3d148c4 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 21:04:49 +0530 Subject: [PATCH 15/59] feat: render usage limits in Settings UI with progress bars --- .../settings/SettingsPanels.browser.tsx | 42 +++++++++++++++++-- .../components/settings/SettingsPanels.tsx | 4 +- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index acb875f3e53..b15f7416548 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -523,7 +523,7 @@ describe("GeneralSettingsPanel observability", () => { source: "claudeStatusProbe", available: false, checkedAt: "2036-04-07T00:00:00.000Z", - reason: "Usage limits unavailable for this Claude account.", + reason: "Unable to fetch usage", windows: [], }, }), @@ -536,9 +536,43 @@ describe("GeneralSettingsPanel observability", () => { , ); - await expect - .element(page.getByText("Usage limits unavailable for this Claude account.")) - .toBeInTheDocument(); + await expect.element(page.getByText("Unable to fetch usage")).toBeInTheDocument(); + }); + + it("renders multiple OpenCode managed usage windows when both subscriptions exist", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + makeProvider("opencode", { + usageLimits: { + source: "opencodeManaged", + available: true, + checkedAt: "2036-04-07T00:00:00.000Z", + windows: [ + { + kind: "session", + label: "OpenCode Go", + usedPercent: 20, + }, + { + kind: "session", + label: "OpenCode Zen", + usedPercent: 40, + }, + ], + }, + }), + ], + }); + + mounted = await render( + + + , + ); + + await expect.element(page.getByLabelText("OpenCode Go usage 20%")).toBeInTheDocument(); + await expect.element(page.getByLabelText("OpenCode Zen usage 40%")).toBeInTheDocument(); }); it("hides provider usage UI for disabled or missing providers", async () => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d9f01d2f772..4508d04b696 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -294,7 +294,7 @@ function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | und if (!provider.usageLimits.available) { return (

- {provider.usageLimits.reason ?? "Usage limits unavailable for this account"} + {provider.usageLimits?.reason ?? "Unable to fetch usage"}

); } @@ -309,7 +309,7 @@ function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | und return (
From 0260b13143875a280818b531fb753019a0cbebec Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sun, 19 Apr 2026 07:27:20 +0530 Subject: [PATCH 16/59] feat: remove check for official provider IDs in flattenOpenCodeModels function --- apps/server/src/provider/opencodeRuntime.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 494118603b4..ee0a2e231f0 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -584,9 +584,6 @@ export function flattenOpenCodeModels( if (!connected.has(provider.id)) { continue; } - if (!officialProviderIds.has(provider.id)) { - continue; - } for (const model of Object.values(provider.models)) { models.push({ From a895cda6cf54928a3753f93b1b62bf0f1ae43be0 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 23 Apr 2026 15:32:41 +0530 Subject: [PATCH 17/59] fix: update account rate limits request signature and error handling in CodexProvider --- apps/server/src/provider/Layers/CodexProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 32960fba3a0..24a620acd23 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -270,7 +270,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun cwds: [input.cwd], }), requestAllCodexModels(client), - client.request("account/rateLimits/read", {}).pipe(Effect.catchAll(() => Effect.succeed(undefined))) + client.request("account/rateLimits/read", undefined).pipe(Effect.catch(() => Effect.void)) ], { concurrency: "unbounded" }, ); From f5e1f470baa993930f641a63a1423f3a0c160962 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 23 Apr 2026 15:47:02 +0530 Subject: [PATCH 18/59] refactor: standardize Codex rate limit window calculations and remove redundant test file --- .../src/provider/Layers/CodexProvider.ts | 27 ++- .../provider/Layers/ProviderRegistry.test.ts | 1 + .../src/provider/codexAppServer.test.ts | 165 ------------------ 3 files changed, 20 insertions(+), 173 deletions(-) delete mode 100644 apps/server/src/provider/codexAppServer.test.ts diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 24a620acd23..53e3da14fae 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -43,7 +43,7 @@ const PROVIDER_PROBE_TIMEOUT_MS = 8_000; export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; - readonly rateLimits: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitSnapshot | undefined; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitSnapshot; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -257,7 +257,6 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, - rateLimits: undefined, version, models: appendCustomCodexModels([], input.customModels ?? []), skills: [], @@ -277,13 +276,16 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, - rateLimits: rateLimitsResponse?.rateLimits ?? undefined, + ...(rateLimitsResponse?.rateLimits ? { rateLimits: rateLimitsResponse.rateLimits } : {}), version, models: appendCustomCodexModels(models, input.customModels ?? []), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; }, Effect.scoped); +const CODEX_PRIMARY_WINDOW_DURATION_MINS = 300; // ~5 hours (short / session window) +const CODEX_SECONDARY_WINDOW_DURATION_MINS = 10080; // 7 days (weekly window) + function resolveCodexManagedUsageLimits( checkedAt: string, rateLimitsSnapshot?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitSnapshot | null, @@ -298,18 +300,27 @@ function resolveCodexManagedUsageLimits( const windows: RawUsageWindowInput[] = []; - const addWindow = (window?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null) => { + const addWindow = ( + window?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null, + fallbackDurationMins?: number, + ) => { if (!window) return; + const durationMins = + typeof window.windowDurationMins === "number" + ? window.windowDurationMins + : fallbackDurationMins; windows.push({ label: "Codex quota window", usedPercent: window.usedPercent, - ...(typeof window.resetsAt === "number" ? { resetsAt: new Date(window.resetsAt * 1000).toISOString() } : {}), - ...(typeof window.windowDurationMins === "number" ? { windowDurationMins: window.windowDurationMins } : {}), + ...(typeof window.resetsAt === "number" + ? { resetsAt: new Date(window.resetsAt * 1000).toISOString() } + : {}), + ...(typeof durationMins === "number" ? { windowDurationMins: durationMins } : {}), }); }; - addWindow(rateLimitsSnapshot.primary); - addWindow(rateLimitsSnapshot.secondary); + addWindow(rateLimitsSnapshot.primary, CODEX_PRIMARY_WINDOW_DURATION_MINS); + addWindow(rateLimitsSnapshot.secondary, CODEX_SECONDARY_WINDOW_DURATION_MINS); return makeUsageLimitsSnapshot({ source: "codexAppServer", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index e5f0f94ba8f..dd449f0c01c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -208,6 +208,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( account: { account: { type: "chatgpt", + email: "test@example.com", planType: "pro", }, requiresOpenaiAuth: false, diff --git a/apps/server/src/provider/codexAppServer.test.ts b/apps/server/src/provider/codexAppServer.test.ts deleted file mode 100644 index 69e6680d156..00000000000 --- a/apps/server/src/provider/codexAppServer.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { normalizeCodexUsageLimits, readCodexRateLimitsSnapshot } from "./codexAppServer.ts"; - -describe("codexAppServer", () => { - it("parses account/rateLimits/read payloads", () => { - const snapshot = readCodexRateLimitsSnapshot({ - rateLimits: { - shortWindow: { - usedPercent: 25, - windowDurationMins: 300, - resetsAt: 1_776_384_000, - }, - longWindow: { - usedPercent: 40, - windowDurationMins: 10_080, - resetsAt: 1_776_988_800, - }, - }, - }); - - expect(snapshot).toEqual({ - windows: [ - { - usedPercent: 25, - windowDurationMins: 300, - resetsAt: "2026-04-17T00:00:00.000Z", - }, - { - usedPercent: 40, - windowDurationMins: 10080, - resetsAt: "2026-04-24T00:00:00.000Z", - }, - ], - }); - }); - - it("prefers rateLimitsByLimitId.codex when present", () => { - const snapshot = readCodexRateLimitsSnapshot({ - rateLimits: { - longWindow: { - usedPercent: 80, - windowDurationMins: 10_080, - }, - }, - rateLimitsByLimitId: { - codex: { - rateLimits: { - shortWindow: { - usedPercent: 20, - windowDurationMins: 300, - }, - longWindow: { - usedPercent: 30, - windowDurationMins: 10_080, - }, - }, - }, - }, - }); - - expect(snapshot).toEqual({ - windows: [ - { - usedPercent: 20, - windowDurationMins: 300, - }, - { - usedPercent: 30, - windowDurationMins: 10080, - }, - ], - }); - }); - - it("tolerates missing rate-limit responses", () => { - expect(readCodexRateLimitsSnapshot(undefined)).toBeUndefined(); - expect( - normalizeCodexUsageLimits({ - checkedAt: "2026-04-17T00:00:00.000Z", - }), - ).toEqual({ - source: "codexAppServer", - available: false, - reason: "No Codex subscription quota windows reported.", - windows: [], - checkedAt: "2026-04-17T00:00:00.000Z", - }); - }); - - it("accepts alternate rate-limit field names and unix-ms reset timestamps", () => { - const snapshot = readCodexRateLimitsSnapshot({ - limits: [ - { - used_percent: "42", - window_duration_seconds: 18_000, - reset_at: 1_776_384_000_000, - }, - ], - }); - - expect(snapshot).toEqual({ - windows: [ - { - usedPercent: 42, - windowDurationMins: 300, - resetsAt: "2026-04-17T00:00:00.000Z", - }, - ], - }); - }); - - it("falls back to session and weekly windows when duration metadata is missing", () => { - const snapshot = readCodexRateLimitsSnapshot({ - rateLimits: { - shortWindow: { - usedPercent: 21, - resetsAt: 1_776_384_000, - }, - longWindow: { - usedPercent: 67, - resetsAt: 1_776_988_800, - }, - }, - }); - - expect(snapshot).toEqual({ - windows: [ - { - usedPercent: 21, - windowDurationMins: 300, - resetsAt: "2026-04-17T00:00:00.000Z", - }, - { - usedPercent: 67, - windowDurationMins: 10080, - resetsAt: "2026-04-24T00:00:00.000Z", - }, - ], - }); - }); - - it("derives usage percent from used and limit fields", () => { - const snapshot = readCodexRateLimitsSnapshot({ - rateLimits: { - shortWindow: { - used: 42, - limit: 100, - windowDurationMins: 300, - resetsAt: 1_776_384_000, - }, - }, - }); - - expect(snapshot).toEqual({ - windows: [ - { - usedPercent: 42, - windowDurationMins: 300, - resetsAt: "2026-04-17T00:00:00.000Z", - }, - ], - }); - }); -}); From 0acd2e24d6e6d034d2d652a40047a798bad0843e Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 23 Apr 2026 15:58:07 +0530 Subject: [PATCH 19/59] refactor: remove unused toIsoDateTimeFromUnixSeconds helper function --- apps/server/src/provider/providerUsageLimits.test.ts | 11 ----------- apps/server/src/provider/providerUsageLimits.ts | 8 -------- 2 files changed, 19 deletions(-) diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts index d1f8beb1592..74211c33b87 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "vitest"; - import { clampPercent, makeUsageLimitsSnapshot, - toIsoDateTimeFromUnixSeconds, windowKindFromDuration, } from "./providerUsageLimits.ts"; @@ -62,13 +60,4 @@ describe("providerUsageLimits", () => { }), ).toBe("weekly"); }); - - it("normalizes unix-second reset timestamps", () => { - expect(toIsoDateTimeFromUnixSeconds(1_713_353_600)).toBe("2024-04-17T11:33:20.000Z"); - }); - - it("drops malformed or out-of-range unix-second reset timestamps", () => { - expect(toIsoDateTimeFromUnixSeconds(Number.MAX_VALUE)).toBeUndefined(); - expect(toIsoDateTimeFromUnixSeconds(Number.POSITIVE_INFINITY)).toBeUndefined(); - }); }); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index 7582fdf741f..7a23ac0e9a2 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -14,14 +14,6 @@ export function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } -export function toIsoDateTimeFromUnixSeconds(value: unknown): string | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) { - return undefined; - } - const date = new Date(value * 1000); - return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; -} - export function windowKindFromDuration(input: { readonly windowDurationMins?: number; readonly shortestWindowDurationMins?: number; From ad00bbe43de9a52640d5fb16370b25cd1b65af35 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 10:06:55 +0530 Subject: [PATCH 20/59] fix: resolve 5 code issues from Cursor Bugbot review + lint warnings - Fix migration ID collision (AuthAccessManagementCompat vs CanonicalizeModelSelectionOptions) - Fix Claude usage probe race condition causing duplicate PTY spawns - Fix Claude usage cache key leak by using single key per provider - Fix "Resets tomorrow" label to use local date comparison instead of fixed ms windows - Fix Codex rate-limits catch to preserve unexpected errors instead of swallowing them - Fix 12 lint warnings: map spreads, function scoping, react-hooks deps, unused vars --- apps/server/src/persistence/Migrations.ts | 2 +- ...> 027_CanonicalizeModelSelectionOptions.ts} | 0 .../src/provider/Layers/ClaudeProvider.ts | 12 +++++++++++- .../src/provider/Layers/CodexProvider.ts | 12 +++++++++++- .../provider/Layers/OpenCodeProvider.test.ts | 1 - .../src/provider/claudeUsageProbe.test.ts | 6 ++++-- apps/server/src/server.test.ts | 2 +- apps/web/src/components/ChatView.tsx | 18 ++++++++++++------ .../src/components/settings/SettingsPanels.tsx | 10 ++++++++-- .../src/environments/runtime/catalog.test.ts | 1 + apps/web/src/modelSelection.ts | 16 +++++++--------- apps/web/src/uiStateStore.test.ts | 1 + 12 files changed, 57 insertions(+), 24 deletions(-) rename apps/server/src/persistence/Migrations/{026_CanonicalizeModelSelectionOptions.ts => 027_CanonicalizeModelSelectionOptions.ts} (100%) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ab13341e644..a44f717a393 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -39,7 +39,7 @@ import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; -import Migration0027 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; +import Migration0027 from "./Migrations/027_CanonicalizeModelSelectionOptions.ts"; /** * Migration loader with all migrations defined inline. diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts similarity index 100% rename from apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts rename to apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index bc763139462..055925a1f4d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -994,12 +994,22 @@ export const ClaudeProviderLive = Layer.effect( ), (input) => Effect.gen(function* () { - const key = JSON.stringify([input.binaryPath, input.launchArgs]); + const key = "claude-usage-probe"; const currentEntry = (yield* Ref.get(usageProbeStateRef)).get(key); const isFresh = currentEntry !== undefined && Date.now() - currentEntry.fetchedAtMs < usageProbeTtlMs; if ((!currentEntry || !isFresh) && !currentEntry?.inFlight) { + yield* Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + const existing = next.get(key); + next.set(key, { + rawOutput: existing?.rawOutput ?? "", + fetchedAtMs: existing?.fetchedAtMs ?? 0, + inFlight: true, + }); + return next; + }); yield* Effect.sync(() => { void Effect.runPromiseExit( refreshUsageProbe(key, input.binaryPath, input.launchArgs), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index c883fdc6840..36e81622807 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -302,7 +302,17 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun cwds: [input.cwd], }), requestAllCodexModels(client), - client.request("account/rateLimits/read", undefined).pipe(Effect.catch(() => Effect.void)) + client.request("account/rateLimits/read", undefined).pipe( + Effect.catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const isExpected = + message.includes("not found") || + message.includes("not available") || + message.includes("no rate limit") || + message.includes("unavailable"); + return isExpected ? Effect.void : Effect.fail(error as CodexErrors.CodexAppServerError); + }), + ), ], { concurrency: "unbounded" }, ); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 052b1f9e315..0a7db3d389e 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -18,7 +18,6 @@ import type { OpenCodeInventory } from "../opencodeRuntime.ts"; const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; - const runtimeMock = { state: { runVersionError: null as Error | null, diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index e35f3cb02d4..272bacf9142 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -61,11 +61,13 @@ import { async function latestSpawnedChild(): Promise { // Wait for dynamic import to resolve and spawn to be called await vi.waitFor(() => { - if (!spawnMock.mock.results.at(-1)?.value) { + const result = spawnMock.mock.results.at(-1)?.value; + if (!result) { throw new Error("Expected node-pty spawn to be called."); } + return result; }); - return spawnMock.mock.results.at(-1)?.value!; + return spawnMock.mock.results.at(-1)!.value as MockPtyChild; } describe("claudeUsageProbe", () => { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 47e159d3036..6f13e4fcdd1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -387,7 +387,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provideMerge(gitCoreLayer), ); - const workspaceAndProjectServicesLayer = Layer.mergeAll( + const _workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, workspaceEntriesLayer, WorkspaceFileSystemLive.pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c27afda975b..9e20b5c4066 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1559,15 +1559,18 @@ export default function ChatView(props: ChatViewProps) { const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); - }, []); + }, [composerRef]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, []); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { + composerRef.current?.addTerminalContext(selection); + }, + [composerRef], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -2325,6 +2328,7 @@ export default function ChatView(props: ChatViewProps) { keybindings, onToggleDiff, toggleTerminalVisibility, + composerRef, ]); const onRevertToTurnCount = useCallback( @@ -2784,7 +2788,7 @@ export default function ChatView(props: ChatViewProps) { promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }, - [activePendingProgress?.activeQuestion, activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -2818,7 +2822,7 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.focusAt(nextCursor); } }, - [activePendingUserInput], + [activePendingUserInput, composerRef], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -2990,6 +2994,7 @@ export default function ChatView(props: ChatViewProps) { setThreadError, autoOpenPlanSidebar, environmentId, + composerRef, ], ); @@ -3125,6 +3130,7 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, autoOpenPlanSidebar, environmentId, + composerRef, ]); const onProviderModelSelect = useCallback( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d73551323a4..175ce26c075 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -272,8 +272,14 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (!Number.isFinite(diffMs) || diffMs <= 0) { return null; } - const dayMs = 24 * 60 * 60 * 1000; - if (diffMs >= dayMs && diffMs < dayMs * 2) { + const now = new Date(); + const resetDate = new Date(resetsAt); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const isResetTomorrow = + resetDate.toDateString() !== now.toDateString() && + resetDate.toDateString() === tomorrow.toDateString(); + if (isResetTomorrow) { return "Resets tomorrow"; } const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index f078129463a..7ccf21811d5 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -95,6 +95,7 @@ describe("environment runtime catalog stores", () => { }); it("does not let stale hydration overwrite records added while hydration is in flight", async () => { + // eslint-disable-next-line unicorn/consistent-function-scoping let resolveRegistryRead: () => void = () => { throw new Error("Registry read resolver was not initialized."); }; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 80e9e241e78..839407241d4 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -64,15 +64,13 @@ export function getAppModelOptions( provider: ProviderKind, selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = getProviderModels(providers, provider).map( - ({ slug, name, shortName, subProvider, isCustom }) => ({ - slug, - name, - ...(shortName ? { shortName } : {}), - ...(subProvider ? { subProvider } : {}), - isCustom, - }), - ); + const options: AppModelOption[] = []; + for (const model of getProviderModels(providers, provider)) { + const option: AppModelOption = { slug: model.slug, name: model.name, isCustom: model.isCustom }; + if (model.shortName) option.shortName = model.shortName; + if (model.subProvider) option.subProvider = model.subProvider; + options.push(option); + } const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); const builtInModelSlugs = new Set( diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index f63208d9ba7..fe5d737854c 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -409,6 +409,7 @@ describe("uiStateStore pure functions", () => { }); describe("uiStateStore persistence round-trip", () => { + // eslint-disable-next-line unicorn/consistent-function-scoping function createLocalStorageStub(): Storage { const store = new Map(); return { From 4d9c423be631e72b0ec7bfa7a2dbbb83abea5165 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 10:36:46 +0530 Subject: [PATCH 21/59] feat: per-thread usage limits tracking + bug fixes - Track usage limits per-thread instead of globally in ProviderUsageState - Fix Claude usage probe to properly detect API key accounts vs usage windows - Fix provider status cache to use fallback usage limits when missing - Fix provider usage limits to preserve custom window labels - Reorder migrations 026/027 to fix migration execution order --- apps/server/src/persistence/Migrations.ts | 8 +-- ... 026_CanonicalizeModelSelectionOptions.ts} | 0 ...t.ts => 027_AuthAccessManagementCompat.ts} | 0 .../src/provider/Layers/ProviderUsageState.ts | 51 +++++++++++++++++-- .../src/provider/claudeUsageProbe.test.ts | 40 +++++++++++++++ apps/server/src/provider/claudeUsageProbe.ts | 18 +++---- .../src/provider/providerStatusCache.ts | 2 +- .../src/provider/providerUsageLimits.ts | 2 +- 8 files changed, 101 insertions(+), 20 deletions(-) rename apps/server/src/persistence/Migrations/{027_CanonicalizeModelSelectionOptions.ts => 026_CanonicalizeModelSelectionOptions.ts} (100%) rename apps/server/src/persistence/Migrations/{026_AuthAccessManagementCompat.ts => 027_AuthAccessManagementCompat.ts} (100%) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a44f717a393..558ced55895 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -38,8 +38,8 @@ import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; -import Migration0026 from "./Migrations/026_AuthAccessManagementCompat.ts"; -import Migration0027 from "./Migrations/027_CanonicalizeModelSelectionOptions.ts"; +import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; +import Migration0027 from "./Migrations/027_AuthAccessManagementCompat.ts"; /** * Migration loader with all migrations defined inline. @@ -80,8 +80,8 @@ export const migrationEntries = [ [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], - [26, "AuthAccessManagementCompat", Migration0026], - [27, "CanonicalizeModelSelectionOptions", Migration0027], + [26, "CanonicalizeModelSelectionOptions", Migration0026], + [27, "AuthAccessManagementCompat", Migration0027], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts similarity index 100% rename from apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts rename to apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts diff --git a/apps/server/src/persistence/Migrations/026_AuthAccessManagementCompat.ts b/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts similarity index 100% rename from apps/server/src/persistence/Migrations/026_AuthAccessManagementCompat.ts rename to apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts diff --git a/apps/server/src/provider/Layers/ProviderUsageState.ts b/apps/server/src/provider/Layers/ProviderUsageState.ts index 8aaa13aac67..5436967affd 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.ts @@ -2,6 +2,7 @@ import type { ProviderKind, ProviderRuntimeEvent, ServerProviderUsageLimits, + ThreadId, } from "@t3tools/contracts"; import { Effect, Layer, Ref, Stream } from "effect"; @@ -32,17 +33,38 @@ export const ProviderUsageStateLive = Layer.effect( ProviderUsageState, Effect.gen(function* () { const providerService = yield* ProviderService; - const stateRef = yield* Ref.make(new Map()); + const stateRef = yield* Ref.make( + new Map>(), + ); const service: ProviderUsageStateShape = { - get: (provider) => Ref.get(stateRef).pipe(Effect.map((state) => state.get(provider))), + get: (provider) => + Ref.get(stateRef).pipe( + Effect.map((state) => { + const threadMap = state.get(provider); + if (!threadMap || threadMap.size === 0) { + return undefined; + } + const global = threadMap.get("global" as ThreadId); + if (global) { + return global; + } + const latest = Array.from(threadMap.values()).at(-1); + return latest; + }), + ), set: (provider, usage) => Ref.update(stateRef, (state) => { const next = new Map(state); if (usage === undefined) { next.delete(provider); } else { - next.set(provider, usage); + let threadMap = next.get(provider); + if (!threadMap) { + threadMap = new Map(); + next.set(provider, threadMap); + } + threadMap.set("global" as ThreadId, usage); } return next; }), @@ -64,7 +86,17 @@ export const ProviderUsageStateLive = Layer.effect( } if (event.type === "session.started" || event.type === "session.exited") { - yield* service.clear("cursor"); + yield* Ref.update(stateRef, (state) => { + const next = new Map(state); + const threadMap = next.get("cursor"); + if (threadMap) { + threadMap.delete(event.threadId); + if (threadMap.size === 0) { + next.delete("cursor"); + } + } + return next; + }); return; } @@ -77,7 +109,16 @@ export const ProviderUsageStateLive = Layer.effect( return; } - yield* service.set("cursor", usage); + yield* Ref.update(stateRef, (state) => { + const next = new Map(state); + let threadMap = next.get("cursor"); + if (!threadMap) { + threadMap = new Map(); + next.set("cursor", threadMap); + } + threadMap.set(event.threadId, usage); + return next; + }); }), ).pipe(Effect.forkScoped); diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index 272bacf9142..70d562ba7d9 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -127,6 +127,46 @@ describe("claudeUsageProbe", () => { }); }); + it("returns unavailable for API key accounts when no windows found", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: "Using API key for authentication", + }), + ).toEqual({ + source: "claudeStatusProbe", + available: false, + checkedAt: "2026-04-17T10:00:00.000Z", + reason: "Usage limits unavailable for Claude API key accounts.", + windows: [], + }); + }); + + it("parses windows even when output contains api key wording", () => { + expect( + parseClaudeUsageLimitsOutput({ + checkedAt: "2026-04-17T10:00:00.000Z", + output: ` + Session usage 42% resets at 2026-04-17T14:00:00Z + To set an API key, use: env ANTHROPIC_API_KEY=sk-... + `, + }), + ).toEqual({ + source: "claudeStatusProbe", + available: true, + checkedAt: "2026-04-17T10:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 42, + windowDurationMins: 300, + resetsAt: "2026-04-17T14:00:00.000Z", + }, + ], + }); + }); + it("requests the /usage fallback for short unavailable status output", () => { expect( shouldRequestClaudeUsageFallback({ diff --git a/apps/server/src/provider/claudeUsageProbe.ts b/apps/server/src/provider/claudeUsageProbe.ts index 061613b27c6..ad84c87eb30 100644 --- a/apps/server/src/provider/claudeUsageProbe.ts +++ b/apps/server/src/provider/claudeUsageProbe.ts @@ -150,29 +150,29 @@ export function parseClaudeUsageLimitsOutput(input: { }): ServerProviderUsageLimits { const cleanedOutput = stripAnsi(input.output); const lowerOutput = cleanedOutput.toLowerCase(); + const windows = extractWindowSegments(cleanedOutput); - if (/\bapi key\b|\bapi-key\b/.test(lowerOutput)) { - return makeUnavailableUsageLimits({ + if (windows.length > 0) { + return makeUsageLimitsSnapshot({ source: "claudeStatusProbe", checkedAt: input.checkedAt, - reason: "Usage limits unavailable for Claude API key accounts.", + windows, + unavailableReason: "Usage limits unavailable for this Claude account.", }); } - const windows = extractWindowSegments(cleanedOutput); - if (windows.length === 0) { + if (/\busing api key\b|\busing.an api.key\b/.test(lowerOutput)) { return makeUnavailableUsageLimits({ source: "claudeStatusProbe", checkedAt: input.checkedAt, - reason: "Usage limits unavailable for this Claude account.", + reason: "Usage limits unavailable for Claude API key accounts.", }); } - return makeUsageLimitsSnapshot({ + return makeUnavailableUsageLimits({ source: "claudeStatusProbe", checkedAt: input.checkedAt, - windows, - unavailableReason: "Usage limits unavailable for this Claude account.", + reason: "Usage limits unavailable for this Claude account.", }); } diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index f1f57d21a2b..0a77e619238 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -57,7 +57,7 @@ export const hydrateCachedProvider = (input: { checkedAt: input.cachedProvider.checkedAt, slashCommands: input.cachedProvider.slashCommands, skills: input.cachedProvider.skills, - usageLimits: input.cachedProvider.usageLimits, + usageLimits: input.cachedProvider.usageLimits ?? fallbackWithoutMessage.usageLimits, }; return input.cachedProvider.message diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index 7a23ac0e9a2..b4aa57bd930 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -63,7 +63,7 @@ export function normalizeUsageWindows( return [ { kind, - label: kind === "session" ? "Session" : "Weekly", + label: window.label || (kind === "session" ? "Session" : "Weekly"), usedPercent: clampPercent(window.usedPercent), ...(window.resetsAt ? { resetsAt: window.resetsAt } : {}), ...(typeof window.windowDurationMins === "number" && From 4437685f4f9b06107a7facce4487e8699a86f7b0 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 11:52:03 +0530 Subject: [PATCH 22/59] refactor: enhance auth session and pairing link migrations - Simplify migration logic for adding columns to auth_sessions and auth_pairing_links. - Ensure columns are only added if they do not already exist. - Update migration files to improve readability and maintainability. --- .../021_AuthSessionClientMetadata.ts | 99 +++++++++---------- .../027_AuthAccessManagementCompat.ts | 56 +++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 40 ++++---- .../src/provider/Layers/CodexProvider.ts | 18 ++-- .../Layers/ProviderUsageState.test.ts | 55 +++++++++++ .../src/provider/Layers/ProviderUsageState.ts | 39 ++++++-- apps/server/src/provider/claudeUsageProbe.ts | 18 +++- .../components/settings/SettingsPanels.tsx | 11 ++- 8 files changed, 236 insertions(+), 100 deletions(-) diff --git a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts index ff27c1ca1de..c4fc91b73c5 100644 --- a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts +++ b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts @@ -11,64 +11,63 @@ export default Effect.gen(function* () { AND name IN ('auth_pairing_links', 'auth_sessions') `; const tableNames = new Set(existingTables.map((table) => table.name)); - - if (!tableNames.has("auth_pairing_links") || !tableNames.has("auth_sessions")) { - return; - } - - const pairingLinkColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_pairing_links) - `; - if (!pairingLinkColumns.some((column) => column.name === "label")) { - yield* sql` - ALTER TABLE auth_pairing_links - ADD COLUMN label TEXT + if (tableNames.has("auth_pairing_links")) { + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) `; + if (!pairingLinkColumns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN label TEXT + `; + } } - const sessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_sessions) - `; - - if (!sessionColumns.some((column) => column.name === "client_label")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_label TEXT + if (tableNames.has("auth_sessions")) { + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) `; - } - if (!sessionColumns.some((column) => column.name === "client_ip_address")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_ip_address TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_label")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_label TEXT + `; + } - if (!sessionColumns.some((column) => column.name === "client_user_agent")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_user_agent TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_ip_address")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_ip_address TEXT + `; + } - if (!sessionColumns.some((column) => column.name === "client_device_type")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' - `; - } + if (!sessionColumns.some((column) => column.name === "client_user_agent")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_user_agent TEXT + `; + } - if (!sessionColumns.some((column) => column.name === "client_os")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_os TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_device_type")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' + `; + } - if (!sessionColumns.some((column) => column.name === "client_browser")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_browser TEXT - `; + if (!sessionColumns.some((column) => column.name === "client_os")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_os TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_browser")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_browser TEXT + `; + } } }); diff --git a/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts b/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts index c2305be6ee5..f46b9ef2b56 100644 --- a/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts +++ b/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts @@ -50,4 +50,60 @@ export default Effect.gen(function* () { CREATE INDEX IF NOT EXISTS idx_auth_sessions_active ON auth_sessions(revoked_at, expires_at, issued_at) `; + + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN label TEXT + `; + } + + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + if (!sessionColumns.some((column) => column.name === "client_label")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_label TEXT + `; + } + if (!sessionColumns.some((column) => column.name === "client_ip_address")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_ip_address TEXT + `; + } + if (!sessionColumns.some((column) => column.name === "client_user_agent")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_user_agent TEXT + `; + } + if (!sessionColumns.some((column) => column.name === "client_device_type")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' + `; + } + if (!sessionColumns.some((column) => column.name === "client_os")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_os TEXT + `; + } + if (!sessionColumns.some((column) => column.name === "client_browser")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_browser TEXT + `; + } + if (!sessionColumns.some((column) => column.name === "last_connected_at")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN last_connected_at TEXT + `; + } }); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 055925a1f4d..dc362f136bd 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -759,14 +759,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) : undefined) ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); - const resolvedUsageLimits = - (resolveUsageLimits - ? yield* resolveUsageLimits({ - binaryPath: claudeSettings.binaryPath, - launchArgs: claudeSettings.launchArgs, - checkedAt, - }).pipe(Effect.orElseSucceed(() => undefined)) - : undefined) ?? undefined; // ── Auth check + subscription detection ──────────────────────────── @@ -793,19 +785,27 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); } - const usageLimits = - normalizeClaudeAuthMethod(authMethod) === "apiKey" - ? makeUnavailableUsageLimits({ - source: "claudeStatusProbe", - checkedAt, - reason: "Usage limits unavailable for Claude API key accounts.", - }) - : (resolvedUsageLimits ?? - makeUnavailableUsageLimits({ - source: "claudeStatusProbe", + const isApiKeyAuth = normalizeClaudeAuthMethod(authMethod) === "apiKey"; + const resolvedUsageLimits = + !isApiKeyAuth && resolveUsageLimits + ? ((yield* resolveUsageLimits({ + binaryPath: claudeSettings.binaryPath, + launchArgs: claudeSettings.launchArgs, checkedAt, - reason: "Usage limits unavailable for this Claude account.", - })); + }).pipe(Effect.orElseSucceed(() => undefined))) ?? undefined) + : undefined; + const usageLimits = isApiKeyAuth + ? makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt, + reason: "Usage limits unavailable for Claude API key accounts.", + }) + : (resolvedUsageLimits ?? + makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt, + reason: "Usage limits unavailable for this Claude account.", + })); // ── Handle auth results (same logic as before, adjusted models) ── diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 36e81622807..f7bc098fb62 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -303,15 +303,8 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun }), requestAllCodexModels(client), client.request("account/rateLimits/read", undefined).pipe( - Effect.catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - const isExpected = - message.includes("not found") || - message.includes("not available") || - message.includes("no rate limit") || - message.includes("unavailable"); - return isExpected ? Effect.void : Effect.fail(error as CodexErrors.CodexAppServerError); - }), + // Rate limits are optional metadata and should not fail the whole provider probe. + Effect.catchAll(() => Effect.void), ), ], { concurrency: "unbounded" }, @@ -346,6 +339,7 @@ function resolveCodexManagedUsageLimits( const addWindow = ( window?: CodexSchema.V2GetAccountRateLimitsResponse__RateLimitWindow | null, fallbackDurationMins?: number, + label?: string, ) => { if (!window) return; const durationMins = @@ -353,7 +347,7 @@ function resolveCodexManagedUsageLimits( ? window.windowDurationMins : fallbackDurationMins; windows.push({ - label: "Codex quota window", + ...(label ? { label } : {}), usedPercent: window.usedPercent, ...(typeof window.resetsAt === "number" ? { resetsAt: new Date(window.resetsAt * 1000).toISOString() } @@ -362,8 +356,8 @@ function resolveCodexManagedUsageLimits( }); }; - addWindow(rateLimitsSnapshot.primary, CODEX_PRIMARY_WINDOW_DURATION_MINS); - addWindow(rateLimitsSnapshot.secondary, CODEX_SECONDARY_WINDOW_DURATION_MINS); + addWindow(rateLimitsSnapshot.primary, CODEX_PRIMARY_WINDOW_DURATION_MINS, "Session"); + addWindow(rateLimitsSnapshot.secondary, CODEX_SECONDARY_WINDOW_DURATION_MINS, "Weekly"); return makeUsageLimitsSnapshot({ source: "codexAppServer", diff --git a/apps/server/src/provider/Layers/ProviderUsageState.test.ts b/apps/server/src/provider/Layers/ProviderUsageState.test.ts index 7d211980455..d7780f9a529 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.test.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.test.ts @@ -84,4 +84,59 @@ describe("ProviderUsageStateLive", () => { expect(state.cursor?.windows).toEqual([{ kind: "session", label: "Session", usedPercent: 50 }]); expect(state.opencode).toBeUndefined(); }); + + it("returns the most recently updated thread usage", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-1" as never, + provider: "cursor", + threadId: "thread-a" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + usage: { + usedTokens: 10, + maxTokens: 100, + }, + }, + }); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-2" as never, + provider: "cursor", + threadId: "thread-b" as never, + createdAt: "2026-04-18T00:01:00.000Z", + payload: { + usage: { + usedTokens: 20, + maxTokens: 100, + }, + }, + }); + yield* PubSub.publish(stub.pubsub, { + type: "thread.token-usage.updated", + eventId: "evt-3" as never, + provider: "cursor", + threadId: "thread-a" as never, + createdAt: "2026-04-18T00:02:00.000Z", + payload: { + usage: { + usedTokens: 60, + maxTokens: 100, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + return yield* usageState.get("cursor"); + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state?.windows).toEqual([{ kind: "session", label: "Session", usedPercent: 60 }]); + }); }); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.ts b/apps/server/src/provider/Layers/ProviderUsageState.ts index 5436967affd..b3001ca7af9 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.ts @@ -34,7 +34,10 @@ export const ProviderUsageStateLive = Layer.effect( Effect.gen(function* () { const providerService = yield* ProviderService; const stateRef = yield* Ref.make( - new Map>(), + new Map< + ProviderKind, + Map + >(), ); const service: ProviderUsageStateShape = { @@ -47,10 +50,17 @@ export const ProviderUsageStateLive = Layer.effect( } const global = threadMap.get("global" as ThreadId); if (global) { - return global; + return global.usage; } - const latest = Array.from(threadMap.values()).at(-1); - return latest; + let latest: + | { readonly usage: ServerProviderUsageLimits; readonly updatedAtMs: number } + | undefined; + for (const entry of threadMap.values()) { + if (!latest || entry.updatedAtMs > latest.updatedAtMs) { + latest = entry; + } + } + return latest?.usage; }), ), set: (provider, usage) => @@ -62,9 +72,11 @@ export const ProviderUsageStateLive = Layer.effect( let threadMap = next.get(provider); if (!threadMap) { threadMap = new Map(); - next.set(provider, threadMap); + } else { + threadMap = new Map(threadMap); } - threadMap.set("global" as ThreadId, usage); + next.set(provider, threadMap); + threadMap.set("global" as ThreadId, { usage, updatedAtMs: Date.now() }); } return next; }), @@ -88,8 +100,10 @@ export const ProviderUsageStateLive = Layer.effect( if (event.type === "session.started" || event.type === "session.exited") { yield* Ref.update(stateRef, (state) => { const next = new Map(state); - const threadMap = next.get("cursor"); - if (threadMap) { + const existingThreadMap = next.get("cursor"); + if (existingThreadMap) { + const threadMap = new Map(existingThreadMap); + next.set("cursor", threadMap); threadMap.delete(event.threadId); if (threadMap.size === 0) { next.delete("cursor"); @@ -114,9 +128,14 @@ export const ProviderUsageStateLive = Layer.effect( let threadMap = next.get("cursor"); if (!threadMap) { threadMap = new Map(); - next.set("cursor", threadMap); + } else { + threadMap = new Map(threadMap); } - threadMap.set(event.threadId, usage); + next.set("cursor", threadMap); + threadMap.set(event.threadId, { + usage, + updatedAtMs: Date.parse(event.createdAt) || Date.now(), + }); return next; }); }), diff --git a/apps/server/src/provider/claudeUsageProbe.ts b/apps/server/src/provider/claudeUsageProbe.ts index ad84c87eb30..5c332ce4731 100644 --- a/apps/server/src/provider/claudeUsageProbe.ts +++ b/apps/server/src/provider/claudeUsageProbe.ts @@ -71,11 +71,21 @@ function detectClaudeUsageWindowKind(value: string): "session" | "weekly" | unde } function extractResetTimestamp(value: string): string | undefined { - const resetMatch = value.match( - /\breset(?:s|ting)?(?:\s+(?:at|on|in))?[:\s-]*([A-Za-z]{3,9}[^,\n]*\d{1,2}[^,\n]*\d{2,4}[^,\n]*|\d{4}-\d{2}-\d{2}T[^\s,]+|\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}(?::\d{2})?(?:\s*[A-Z]{2,5})?)/i, - ); - const candidate = resetMatch?.[1]?.trim(); + const resetMatch = value.match(/\breset(?:s|ting)?(?:\s+(?:at|on|in))?[:\s-]*([^\n.;]+)/i); + const candidate = resetMatch?.[1] + ?.trim() + .replace(/\s+/g, " ") + .replace(/\b(?:local time|your time|time)\b.*$/i, "") + .trim(); if (!candidate) return undefined; + if (/\b(?:today|tomorrow|tonight|next)\b/i.test(candidate)) { + return undefined; + } + const hasExplicitTimezone = + /(?:z|[+-]\d{2}:?\d{2}|\b(?:utc|gmt|p[sd]t|m[sd]t|c[sd]t|e[sd]t)\b)/i.test(candidate); + if (!hasExplicitTimezone) { + return undefined; + } const parsed = Date.parse(candidate); return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 175ce26c075..7ee365d1573 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -272,6 +272,13 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (!Number.isFinite(diffMs) || diffMs <= 0) { return null; } + const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); + if (relativeLabel === "Soon") { + return "Resets soon"; + } + if (diffMs < 6 * 60 * 60 * 1000) { + return `Resets in ${relativeLabel.replace(/ left$/, "")}`; + } const now = new Date(); const resetDate = new Date(resetsAt); const tomorrow = new Date(now); @@ -282,10 +289,6 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (isResetTomorrow) { return "Resets tomorrow"; } - const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); - if (relativeLabel === "Soon") { - return "Resets soon"; - } return `Resets in ${relativeLabel.replace(/ left$/, "")}`; } From e0895471b06e2b2ed334f569a78addc6720c8d09 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 13:16:43 +0530 Subject: [PATCH 23/59] fix: usage state bugs - cache key collision, labeling, and asymmetric key handling - Fix Claude usage probe cache key collision: use binaryPath instead of hardcoded 'claude-usage-probe' key, preventing cross-contamination when switching between Claude installations/accounts - Fix Cursor usage labeling: change 'Session' to 'Context window' to accurately reflect that ACP usage_update events track context-window utilization (size/used tokens), not subscription quota - Fix ProviderUsageState asymmetric key handling: require explicit threadId in set() and remove 'global' key short-circuit in get(). This ensures per-thread entries are properly isolated and prevents future misuse --- .../src/provider/Layers/ClaudeProvider.ts | 2 +- .../provider/Layers/CursorProvider.test.ts | 2 +- .../Layers/ProviderUsageState.test.ts | 12 ++++++------ .../src/provider/Layers/ProviderUsageState.ts | 19 ++++++++++++------- .../provider/Services/ProviderUsageState.ts | 3 ++- .../runtimeUsageToProviderUsageLimits.test.ts | 2 +- .../runtimeUsageToProviderUsageLimits.ts | 2 +- packages/contracts/src/server.test.ts | 2 +- 8 files changed, 25 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index dc362f136bd..a139b02c193 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -994,7 +994,7 @@ export const ClaudeProviderLive = Layer.effect( ), (input) => Effect.gen(function* () { - const key = "claude-usage-probe"; + const key = input.binaryPath; const currentEntry = (yield* Ref.get(usageProbeStateRef)).get(key); const isFresh = currentEntry !== undefined && Date.now() - currentEntry.fetchedAtMs < usageProbeTtlMs; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 997dae18288..7f9630ea8d6 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -598,7 +598,7 @@ describe("buildCursorProviderSnapshot", () => { source: "cursorAcp", available: true, checkedAt: "2026-04-18T00:00:00.000Z", - windows: [{ kind: "session", label: "Session", usedPercent: 50 }], + windows: [{ kind: "session", label: "Context window", usedPercent: 50 }], }, }); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.test.ts b/apps/server/src/provider/Layers/ProviderUsageState.test.ts index d7780f9a529..6b619042442 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.test.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Effect, Layer, PubSub, Stream } from "effect"; -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; import { ProviderService } from "../Services/ProviderService.ts"; @@ -33,11 +33,11 @@ describe("ProviderUsageStateLive", () => { Effect.gen(function* () { const usageState = yield* ProviderUsageState; - yield* usageState.set("cursor", { + yield* usageState.set("cursor", "thread-probe" as ThreadId, { source: "cursorAcp", available: true, checkedAt: "2026-04-18T00:00:00.000Z", - windows: [{ kind: "session", label: "Session", usedPercent: 25 }], + windows: [{ kind: "session", label: "Context window", usedPercent: 25 }], }); const first = yield* usageState.get("cursor"); yield* usageState.clear("cursor"); @@ -47,7 +47,7 @@ describe("ProviderUsageStateLive", () => { }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), ); - expect(result.first?.windows).toEqual([{ kind: "session", label: "Session", usedPercent: 25 }]); + expect(result.first?.windows).toEqual([{ kind: "session", label: "Context window", usedPercent: 25 }]); expect(result.second).toBeUndefined(); }); @@ -81,7 +81,7 @@ describe("ProviderUsageStateLive", () => { }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), ); - expect(state.cursor?.windows).toEqual([{ kind: "session", label: "Session", usedPercent: 50 }]); + expect(state.cursor?.windows).toEqual([{ kind: "session", label: "Context window", usedPercent: 50 }]); expect(state.opencode).toBeUndefined(); }); @@ -137,6 +137,6 @@ describe("ProviderUsageStateLive", () => { }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), ); - expect(state?.windows).toEqual([{ kind: "session", label: "Session", usedPercent: 60 }]); + expect(state?.windows).toEqual([{ kind: "session", label: "Context window", usedPercent: 60 }]); }); }); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.ts b/apps/server/src/provider/Layers/ProviderUsageState.ts index b3001ca7af9..28d3555ead7 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.ts @@ -48,10 +48,6 @@ export const ProviderUsageStateLive = Layer.effect( if (!threadMap || threadMap.size === 0) { return undefined; } - const global = threadMap.get("global" as ThreadId); - if (global) { - return global.usage; - } let latest: | { readonly usage: ServerProviderUsageLimits; readonly updatedAtMs: number } | undefined; @@ -63,11 +59,20 @@ export const ProviderUsageStateLive = Layer.effect( return latest?.usage; }), ), - set: (provider, usage) => + set: (provider, threadId, usage) => Ref.update(stateRef, (state) => { const next = new Map(state); if (usage === undefined) { - next.delete(provider); + const existingThreadMap = next.get(provider); + if (existingThreadMap) { + const newThreadMap = new Map(existingThreadMap); + newThreadMap.delete(threadId); + if (newThreadMap.size === 0) { + next.delete(provider); + } else { + next.set(provider, newThreadMap); + } + } } else { let threadMap = next.get(provider); if (!threadMap) { @@ -76,7 +81,7 @@ export const ProviderUsageStateLive = Layer.effect( threadMap = new Map(threadMap); } next.set(provider, threadMap); - threadMap.set("global" as ThreadId, { usage, updatedAtMs: Date.now() }); + threadMap.set(threadId, { usage, updatedAtMs: Date.now() }); } return next; }), diff --git a/apps/server/src/provider/Services/ProviderUsageState.ts b/apps/server/src/provider/Services/ProviderUsageState.ts index 7b9d828d1c2..dd00f3b7cfa 100644 --- a/apps/server/src/provider/Services/ProviderUsageState.ts +++ b/apps/server/src/provider/Services/ProviderUsageState.ts @@ -1,4 +1,4 @@ -import type { ProviderKind, ServerProviderUsageLimits } from "@t3tools/contracts"; +import type { ProviderKind, ServerProviderUsageLimits, ThreadId } from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect } from "effect"; @@ -6,6 +6,7 @@ export interface ProviderUsageStateShape { readonly get: (provider: ProviderKind) => Effect.Effect; readonly set: ( provider: ProviderKind, + threadId: ThreadId, usage: ServerProviderUsageLimits | undefined, ) => Effect.Effect; readonly clear: (provider: ProviderKind) => Effect.Effect; diff --git a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts index 4d8c40184f2..42a49223cf5 100644 --- a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts +++ b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.test.ts @@ -15,7 +15,7 @@ describe("runtimeUsageToProviderUsageLimits", () => { source: "cursorAcp", available: true, checkedAt: "2026-04-18T00:00:00.000Z", - windows: [{ kind: "session", label: "Session", usedPercent: 75 }], + windows: [{ kind: "session", label: "Context window", usedPercent: 75 }], }); }); diff --git a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts index ed84e32e26a..74526b4c129 100644 --- a/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts +++ b/apps/server/src/provider/runtimeUsageToProviderUsageLimits.ts @@ -30,7 +30,7 @@ export function runtimeUsageToProviderUsageLimits(input: { windows: [ { kind: "session", - label: input.label?.trim() || "Session", + label: input.label?.trim() || "Context window", usedPercent: clampPercent(rawPercent), }, ], diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index b546efcb660..7cb7c465a7a 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -102,7 +102,7 @@ describe("ServerProvider", () => { source: "cursorAcp", available: true, checkedAt: "2026-04-10T00:00:00.000Z", - windows: [{ kind: "session", label: "Session", usedPercent: 12 }], + windows: [{ kind: "session", label: "Context window", usedPercent: 12 }], }, }); const openCodeParsed = decodeServerProvider({ From e934d22ba941207e34f00595fcff25f9a7f418f7 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 30 Apr 2026 13:25:13 +0530 Subject: [PATCH 24/59] Improvement in provider usage limits UI --- .../027_028_ProviderInstanceIdColumns.test.ts | 74 ------------ .../027_AuthAccessManagementCompat.ts | 109 ------------------ .../src/provider/Layers/ClaudeProvider.ts | 23 ++++ .../src/provider/Layers/CursorProvider.ts | 20 +++- .../src/provider/Layers/OpenCodeProvider.ts | 15 +++ .../settings/ProviderInstanceCard.tsx | 52 +++++++++ 6 files changed, 109 insertions(+), 184 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts delete mode 100644 apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts deleted file mode 100644 index 3233f5043af..00000000000 --- a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); - -layer("027_028_ProviderInstanceIdColumns", (it) => { - it.effect("continues when provider_session_runtime was partially migrated", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 26 }); - yield* sql` - ALTER TABLE provider_session_runtime - ADD COLUMN provider_instance_id TEXT - `; - - yield* runMigrations({ toMigrationInclusive: 28 }); - - const migrations = yield* sql<{ - readonly migration_id: number; - readonly name: string; - }>` - SELECT migration_id, name - FROM effect_sql_migrations - WHERE migration_id IN (27, 28) - ORDER BY migration_id - `; - assert.deepStrictEqual(migrations, [ - { - migration_id: 27, - name: "ProviderSessionRuntimeInstanceId", - }, - { - migration_id: 28, - name: "ProjectionThreadSessionInstanceId", - }, - ]); - - const providerSessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(provider_session_runtime) - `; - assert.ok(providerSessionColumns.some((column) => column.name === "provider_instance_id")); - - const projectionThreadSessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(projection_thread_sessions) - `; - assert.ok( - projectionThreadSessionColumns.some((column) => column.name === "provider_instance_id"), - ); - - const providerSessionIndexes = yield* sql<{ readonly name: string }>` - PRAGMA index_list(provider_session_runtime) - `; - assert.ok( - providerSessionIndexes.some( - (index) => index.name === "idx_provider_session_runtime_instance", - ), - ); - - const projectionThreadSessionIndexes = yield* sql<{ readonly name: string }>` - PRAGMA index_list(projection_thread_sessions) - `; - assert.ok( - projectionThreadSessionIndexes.some( - (index) => index.name === "idx_projection_thread_sessions_instance", - ), - ); - }), - ); -}); diff --git a/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts b/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts deleted file mode 100644 index f46b9ef2b56..00000000000 --- a/apps/server/src/persistence/Migrations/027_AuthAccessManagementCompat.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -// Compatibility repair for databases where migration ID 20 was already consumed -// before auth access tables were introduced. This recreates the intended schema -// without disturbing databases that already applied the auth migrations normally. -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE IF NOT EXISTS auth_pairing_links ( - id TEXT PRIMARY KEY, - credential TEXT NOT NULL UNIQUE, - method TEXT NOT NULL, - role TEXT NOT NULL, - subject TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - consumed_at TEXT, - revoked_at TEXT, - label TEXT - ) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active - ON auth_pairing_links(revoked_at, consumed_at, expires_at) - `; - - yield* sql` - CREATE TABLE IF NOT EXISTS auth_sessions ( - session_id TEXT PRIMARY KEY, - subject TEXT NOT NULL, - role TEXT NOT NULL, - method TEXT NOT NULL, - issued_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - revoked_at TEXT, - client_label TEXT, - client_ip_address TEXT, - client_user_agent TEXT, - client_device_type TEXT NOT NULL DEFAULT 'unknown', - client_os TEXT, - client_browser TEXT, - last_connected_at TEXT - ) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_auth_sessions_active - ON auth_sessions(revoked_at, expires_at, issued_at) - `; - - const pairingLinkColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_pairing_links) - `; - if (!pairingLinkColumns.some((column) => column.name === "label")) { - yield* sql` - ALTER TABLE auth_pairing_links - ADD COLUMN label TEXT - `; - } - - const sessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_sessions) - `; - if (!sessionColumns.some((column) => column.name === "client_label")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_label TEXT - `; - } - if (!sessionColumns.some((column) => column.name === "client_ip_address")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_ip_address TEXT - `; - } - if (!sessionColumns.some((column) => column.name === "client_user_agent")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_user_agent TEXT - `; - } - if (!sessionColumns.some((column) => column.name === "client_device_type")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' - `; - } - if (!sessionColumns.some((column) => column.name === "client_os")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_os TEXT - `; - } - if (!sessionColumns.some((column) => column.name === "client_browser")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_browser TEXT - `; - } - if (!sessionColumns.some((column) => column.name === "last_connected_at")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN last_connected_at TEXT - `; - } -}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 43505967002..ca51e415d8c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -34,6 +34,8 @@ import { } from "../providerSnapshot.ts"; import { compareCliVersions } from "../cliVersion.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { probeClaudeUsageLimits } from "../claudeUsageProbe.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -640,6 +642,26 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + // Run the Claude usage probe — best-effort; failures are swallowed so they + // never block the main provider snapshot. + const usageLimits = yield* Effect.tryPromise(() => + probeClaudeUsageLimits({ + binaryPath: claudeSettings.binaryPath, + cwd: process.cwd(), + checkedAt, + }).then((result) => result.usageLimits), + ).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.map((opt) => Option.getOrUndefined(opt)), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt, + reason: "Unable to fetch usage", + }), + ), + ); + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, @@ -660,6 +682,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(authMetadata ? authMetadata : {}), }, ...(opus47UpgradeMessage ? { message: opus47UpgradeMessage } : {}), + ...(usageLimits ? { usageLimits } : {}), }, }); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index ad52f63fbb2..cd8d863b681 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -29,6 +29,7 @@ import { type CommandResult, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); @@ -646,7 +647,7 @@ export const discoverCursorModelCapabilitiesViaAcp = ( Effect.retry({ times: 3 }), Effect.withSpan("cursor-acp-model-capability-probe"), Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP capability probe failed", { + Effect.logDebug("Cursor ACP capability probe failed", { modelSlug, cause: Cause.pretty(cause), }), @@ -656,13 +657,21 @@ export const discoverCursorModelCapabilitiesViaAcp = ( { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, ); + const failedModels: Array = []; for (const entry of probedCapabilities) { if (!entry) { + failedModels.push("unknown"); continue; } capabilitiesBySlug.set(entry[0], entry[1]); } + if (failedModels.length > 0) { + yield* Effect.logWarning( + `Cursor ACP capability probe failed for ${failedModels.length} model(s) — Cursor Agent may be unresponsive`, + ); + } + return buildCursorDiscoveredModels( modelChoices.map((modelChoice) => ({ slug: modelChoice.value.trim(), @@ -721,6 +730,14 @@ export function buildCursorProviderSnapshot(input: { readonly discoveryWarning?: string; }): ServerProviderDraft { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); + const usageLimits = + input.parsed.auth.status === "authenticated" + ? makeUnavailableUsageLimits({ + source: "cursorAcp", + checkedAt: input.checkedAt, + reason: "Cursor Agent CLI does not expose usage information", + }) + : undefined; return buildServerProvider({ presentation: CURSOR_PRESENTATION, enabled: input.cursorSettings.enabled, @@ -738,6 +755,7 @@ export function buildCursorProviderSnapshot(input: { input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, auth: input.parsed.auth, ...(message ? { message } : {}), + ...(usageLimits ? { usageLimits } : {}), }, }); } diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index c7487d7d526..e14d5f9aff0 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -21,6 +21,8 @@ import { openCodeRuntimeErrorDetail, type OpenCodeInventory, } from "../opencodeRuntime.ts"; +import { resolveOpenCodeManagedUsageLimits } from "../openCodeUsageLimits.ts"; +import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = ProviderDriverKind.make("opencode"); @@ -447,6 +449,18 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); const connectedCount = inventoryExit.value.providerList.connected.length; + const usageLimits = + resolveOpenCodeManagedUsageLimits({ + checkedAt, + inventory: inventoryExit.value, + }) ?? + (connectedCount > 0 + ? makeUnavailableUsageLimits({ + source: "opencodeManaged", + checkedAt, + reason: "Upstream providers did not report usage information", + }) + : undefined); return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, @@ -466,6 +480,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu : isExternalServer ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." : "OpenCode is available, but it did not report any connected upstream providers.", + ...(usageLimits ? { usageLimits } : {}), }, }); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 21889412c30..eae11715f1b 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -10,6 +10,7 @@ import { type ProviderDriverKind, type ServerProvider, type ServerProviderModel, + type ServerProviderUsageLimits, } from "@t3tools/contracts"; import { cn } from "../../lib/utils"; @@ -30,6 +31,56 @@ import { type ProviderStatusKey, } from "./providerStatus"; +function usageBarColor(percent: number): string { + if (percent >= 90) return "bg-destructive"; + if (percent >= 75) return "bg-warning"; + return "bg-success"; +} + +function ProviderUsageBars(props: { + readonly usageLimits: ServerProviderUsageLimits | undefined; + readonly enabled: boolean; +}) { + if (!props.enabled || !props.usageLimits) return null; + + const { usageLimits } = props; + + if (!usageLimits.available) { + return ( +

+ {usageLimits.reason ?? "Usage data unavailable"} +

+ ); + } + + if (usageLimits.windows.length === 0) return null; + + return ( +
+ {usageLimits.windows.map((window) => { + const color = usageBarColor(window.usedPercent); + return ( +
+
+ {window.label} + {Math.round(window.usedPercent)}% +
+
+
+ ); + })} +
+ ); +} + const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", "#16a34a", @@ -702,6 +753,7 @@ export function ProviderInstanceCard({ )} {summary.detail ? - {summary.detail} : null}

+
+ > +
+
); })} From 99511d106d27ffd81eb2ea22e08bff00d11439fc Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 1 May 2026 14:00:26 +0530 Subject: [PATCH 28/59] Enhance provider usage display with reset date and improved styling --- .../src/provider/Layers/ClaudeProvider.ts | 1 + .../settings/ProviderInstanceCard.tsx | 36 ++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 1d58a3a930c..b4d4ed77a1d 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -649,6 +649,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const usageLimits = yield* Effect.tryPromise(() => probeClaudeUsageLimits({ binaryPath: claudeSettings.binaryPath, + launchArgs: claudeSettings.launchArgs, cwd: process.cwd(), checkedAt, environment: claudeEnvironment, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 1487233cdc6..1f935a09ef1 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -31,10 +31,12 @@ import { type ProviderStatusKey, } from "./providerStatus"; +import { formatRelativeTimeUntil } from "../../timestampFormat"; + function usageBarColor(percent: number): string { if (percent >= 90) return "bg-destructive"; if (percent >= 75) return "bg-warning"; - return "bg-success"; + return "bg-foreground"; } function ProviderUsageBars(props: { @@ -56,20 +58,33 @@ function ProviderUsageBars(props: { if (usageLimits.windows.length === 0) return null; return ( -
+
{usageLimits.windows.map((window) => { const color = usageBarColor(window.usedPercent); + const roundedPercent = Math.round(window.usedPercent); + const remainingPercent = 100 - roundedPercent; + + const resetDateStr = window.resetsAt + ? new Date(window.resetsAt).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : null; + return ( -
-
- {window.label} - {Math.round(window.usedPercent)}% +
+
+ {window.label} + {remainingPercent}% remaining
@@ -78,6 +93,11 @@ function ProviderUsageBars(props: { style={{ width: `${Math.max(2, Math.min(100, window.usedPercent))}%` }} />
+ {resetDateStr && ( +
+ Resets {resetDateStr} +
+ )}
); })} From 174dda2eb85cf3d2429ec9f245ebcd50e73cd2d6 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 1 May 2026 15:26:24 +0530 Subject: [PATCH 29/59] Add ProviderUsageStateLive integration and improve usage bar styling --- apps/server/src/server.ts | 2 ++ .../src/components/settings/ProviderInstanceCard.tsx | 10 +++------- .../src/components/settings/SettingsPanels.browser.tsx | 6 ++++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 85f2d84ad28..5ecfbc954e8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -22,6 +22,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { ProviderUsageStateLive } from "./provider/Layers/ProviderUsageState.ts"; import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; @@ -131,6 +132,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(RuntimeReceiptBusLive), + Layer.provideMerge(ProviderUsageStateLive), ); const CheckpointingLayerLive = Layer.empty.pipe( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 1f935a09ef1..5a4305feec0 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -31,8 +31,6 @@ import { type ProviderStatusKey, } from "./providerStatus"; -import { formatRelativeTimeUntil } from "../../timestampFormat"; - function usageBarColor(percent: number): string { if (percent >= 90) return "bg-destructive"; if (percent >= 75) return "bg-warning"; @@ -63,7 +61,7 @@ function ProviderUsageBars(props: { const color = usageBarColor(window.usedPercent); const roundedPercent = Math.round(window.usedPercent); const remainingPercent = 100 - roundedPercent; - + const resetDateStr = window.resetsAt ? new Date(window.resetsAt).toLocaleString("en-GB", { day: "numeric", @@ -90,13 +88,11 @@ function ProviderUsageBars(props: { >
{resetDateStr && ( -
- Resets {resetDateStr} -
+
Resets {resetDateStr}
)}
); diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 224e530b890..cf1d9b61895 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -637,7 +637,8 @@ describe("GeneralSettingsPanel observability", () => { const warningBar = page.getByLabelText("Session usage 88%"); await expect.element(warningBar).toBeInTheDocument(); - await expect.element(warningBar).toHaveClass(/bg-warning/); + const warningInnerBar = warningBar.element().querySelector("div"); + expect(warningInnerBar).toHaveClass(/bg-warning/); setServerConfigSnapshot({ ...createBaseServerConfig(), @@ -661,7 +662,8 @@ describe("GeneralSettingsPanel observability", () => { const dangerBar = page.getByLabelText("Session usage 93%"); await expect.element(dangerBar).toBeInTheDocument(); - await expect.element(dangerBar).toHaveClass(/bg-destructive/); + const dangerInnerBar = dangerBar.element().querySelector("div"); + expect(dangerInnerBar).toHaveClass(/bg-destructive/); }); it("creates and shows a pairing link when network access is enabled", async () => { From 8938dba193a6f0528cccd71a1873f3e3e4b59e78 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sun, 3 May 2026 12:15:24 +0530 Subject: [PATCH 30/59] feat(tests): add migration tests for provider_instance_id column --- .../027_028_ProviderInstanceIdColumns.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts new file mode 100644 index 00000000000..3233f5043af --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -0,0 +1,74 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("027_028_ProviderInstanceIdColumns", (it) => { + it.effect("continues when provider_session_runtime was partially migrated", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 26 }); + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN provider_instance_id TEXT + `; + + yield* runMigrations({ toMigrationInclusive: 28 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id IN (27, 28) + ORDER BY migration_id + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 27, + name: "ProviderSessionRuntimeInstanceId", + }, + { + migration_id: 28, + name: "ProjectionThreadSessionInstanceId", + }, + ]); + + const providerSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(provider_session_runtime) + `; + assert.ok(providerSessionColumns.some((column) => column.name === "provider_instance_id")); + + const projectionThreadSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionColumns.some((column) => column.name === "provider_instance_id"), + ); + + const providerSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(provider_session_runtime) + `; + assert.ok( + providerSessionIndexes.some( + (index) => index.name === "idx_provider_session_runtime_instance", + ), + ); + + const projectionThreadSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionIndexes.some( + (index) => index.name === "idx_projection_thread_sessions_instance", + ), + ); + }), + ); +}); From e68c351a615b0a88d990a33642e107e01d2dbe7b Mon Sep 17 00:00:00 2001 From: aditya mer Date: Mon, 4 May 2026 12:16:13 +0530 Subject: [PATCH 31/59] feat(server): add provider usage limits with PTY adapter integration - Refactor claudeUsageProbe to use shared PtyAdapter instead of ad-hoc node-pty - Add parseClaudeRuntimeUsageLimits for SDK rate-limit event parsing - Add ProviderUsageState layer with tests - Integrate usage state into ClaudeDriver and ClaudeProvider - Add migration 029 for auth session compatibility columns (keep 021 intact) - Update server and tests for usage limits wiring --- apps/server/src/persistence/Migrations.ts | 2 + .../022_AuthSessionLastConnectedAt.ts | 11 - .../029_AuthCompatibilityColumns.ts | 81 +++++++ .../src/provider/Drivers/ClaudeDriver.ts | 10 +- .../src/provider/Layers/ClaudeProvider.ts | 44 ++-- .../Layers/ProviderUsageState.test.ts | 75 ++++++ .../src/provider/Layers/ProviderUsageState.ts | 95 +++++--- .../src/provider/claudeUsageProbe.test.ts | 213 +++++++++++++----- apps/server/src/provider/claudeUsageProbe.ts | 172 ++++++++++---- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 5 +- 11 files changed, 541 insertions(+), 169 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 025e9e4831a..b4c6162f5ac 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -41,6 +41,7 @@ import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingAppro import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts"; import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; +import Migration0029 from "./Migrations/029_AuthCompatibilityColumns.ts"; /** * Migration loader with all migrations defined inline. @@ -81,6 +82,7 @@ export const migrationEntries = [ [26, "CanonicalizeModelSelectionOptions", Migration0026], [27, "ProviderSessionRuntimeInstanceId", Migration0027], [28, "ProjectionThreadSessionInstanceId", Migration0028], + [29, "AuthCompatibilityColumns", Migration0029], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts index 8b21ef845f6..e806a073a55 100644 --- a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts +++ b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts @@ -4,17 +4,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const existingTables = yield* sql<{ readonly name: string }>` - SELECT name - FROM sqlite_master - WHERE type = 'table' - AND name = 'auth_sessions' - `; - - if (existingTables.length === 0) { - return; - } - const sessionColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(auth_sessions) `; diff --git a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts new file mode 100644 index 00000000000..0866db0e05c --- /dev/null +++ b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts @@ -0,0 +1,81 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const existingTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name IN ('auth_pairing_links', 'auth_sessions') + `; + const tableNames = new Set(existingTables.map((table) => table.name)); + + if (tableNames.has("auth_pairing_links")) { + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN label TEXT + `; + } + } + + if (tableNames.has("auth_sessions")) { + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!sessionColumns.some((column) => column.name === "client_label")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_label TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_ip_address")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_ip_address TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_user_agent")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_user_agent TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_device_type")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' + `; + } + + if (!sessionColumns.some((column) => column.name === "client_os")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_os TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_browser")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_browser TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "last_connected_at")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN last_connected_at TEXT + `; + } + } +}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 311f4958651..e1982346f1c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -13,7 +13,7 @@ * @module provider/Drivers/ClaudeDriver */ import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { Cache, Duration, Effect, FileSystem, Option, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; @@ -35,6 +35,8 @@ import { import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { PtyAdapter } from "../../terminal/Services/PTY.ts"; +import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); @@ -75,6 +77,10 @@ export const ClaudeDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const ptyAdapter = Option.getOrUndefined(yield* Effect.serviceOption(PtyAdapter)); + const providerUsageState = Option.getOrUndefined( + yield* Effect.serviceOption(ProviderUsageState), + ); const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ @@ -114,6 +120,8 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, + ptyAdapter ?? undefined, + providerUsageState, ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b4d4ed77a1d..3b91a2dda5f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -36,6 +36,8 @@ import { compareCliVersions } from "../cliVersion.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { probeClaudeUsageLimits } from "../claudeUsageProbe.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; +import type { PtyAdapterShape } from "../../terminal/Services/PTY.ts"; +import type { ProviderUsageStateShape } from "../Services/ProviderUsageState.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -518,6 +520,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings: ClaudeSettings, ) => Effect.Effect, environment: NodeJS.ProcessEnv = process.env, + ptyAdapter?: PtyAdapterShape, + providerUsageState?: ProviderUsageStateShape, ): Effect.fn.Return< ServerProviderDraft, never, @@ -643,26 +647,28 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const runtimeUsageLimits = providerUsageState + ? yield* providerUsageState.get(PROVIDER).pipe(Effect.orElseSucceed(() => undefined)) + : undefined; - // Run the Claude usage probe — best-effort; failures are swallowed so they - // never block the main provider snapshot. - const usageLimits = yield* Effect.tryPromise(() => - probeClaudeUsageLimits({ - binaryPath: claudeSettings.binaryPath, - launchArgs: claudeSettings.launchArgs, - cwd: process.cwd(), - checkedAt, - environment: claudeEnvironment, - }).then((result) => result.usageLimits), - ).pipe( - Effect.orElseSucceed(() => - makeUnavailableUsageLimits({ - source: "claudeStatusProbe", - checkedAt, - reason: "Unable to fetch usage", - }), - ), - ); + const usageLimits = runtimeUsageLimits + ? runtimeUsageLimits + : ptyAdapter + ? yield* probeClaudeUsageLimits( + { + binaryPath: claudeSettings.binaryPath, + launchArgs: claudeSettings.launchArgs, + cwd: process.cwd(), + checkedAt, + environment: claudeEnvironment, + }, + ptyAdapter, + ).pipe(Effect.map((result) => result.usageLimits)) + : makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt, + reason: "Usage limits unavailable for this Claude instance in the current runtime.", + }); const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/Layers/ProviderUsageState.test.ts b/apps/server/src/provider/Layers/ProviderUsageState.test.ts index 02b4ac1cb8c..120211ef9a6 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.test.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.test.ts @@ -144,4 +144,79 @@ describe("ProviderUsageStateLive", () => { expect(state?.windows).toEqual([{ kind: "session", label: "Context window", usedPercent: 60 }]); }); + + it("ingests Claude runtime rate limit telemetry when utilization is present", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "account.rate-limits.updated", + eventId: "evt-claude-1" as never, + provider: ProviderDriverKind.make("claudeAgent"), + threadId: "thread-claude-1" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "seven_day_opus", + utilization: 64, + resetsAt: 1776448800, + }, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + return yield* usageState.get(ProviderDriverKind.make("claudeAgent")); + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state?.windows).toEqual([ + { + kind: "weekly", + label: "Weekly", + usedPercent: 64, + windowDurationMins: 10080, + resetsAt: "2026-04-17T18:00:00.000Z", + }, + ]); + }); + + it("ignores Claude runtime rate limit telemetry when utilization is absent", async () => { + const stub = makeProviderServiceStub(); + const state = await Effect.runPromise( + Effect.gen(function* () { + const usageState = yield* ProviderUsageState; + + yield* Effect.sleep("10 millis"); + yield* PubSub.publish(stub.pubsub, { + type: "account.rate-limits.updated", + eventId: "evt-claude-2" as never, + provider: ProviderDriverKind.make("claudeAgent"), + threadId: "thread-claude-2" as never, + createdAt: "2026-04-18T00:00:00.000Z", + payload: { + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "five_hour", + resetsAt: 1776448800, + }, + }, + }, + }); + + yield* Effect.sleep("10 millis"); + return yield* usageState.get(ProviderDriverKind.make("claudeAgent")); + }).pipe(Effect.provide(ProviderUsageStateLive.pipe(Layer.provide(stub.layer)))), + ); + + expect(state).toBeUndefined(); + }); }); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.ts b/apps/server/src/provider/Layers/ProviderUsageState.ts index d2e2bcd1adb..2b7553cb1c0 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.ts @@ -7,6 +7,7 @@ import type { import { ProviderDriverKind as ProviderDriverKindSchema } from "@t3tools/contracts"; import { Effect, Layer, Ref, Stream } from "effect"; +import { parseClaudeRuntimeUsageLimits } from "../claudeUsageProbe.ts"; import { runtimeUsageToProviderUsageLimits } from "../runtimeUsageToProviderUsageLimits.ts"; import { ProviderUsageState, @@ -15,6 +16,7 @@ import { import { ProviderService } from "../Services/ProviderService.ts"; const CURSOR_DRIVER = ProviderDriverKindSchema.make("cursor"); +const CLAUDE_DRIVER = ProviderDriverKindSchema.make("claudeAgent"); function toCursorUsageLimits( event: Extract, @@ -43,6 +45,37 @@ export const ProviderUsageStateLive = Layer.effect( >(), ); + const clearThreadUsage = (provider: ProviderDriverKind, threadId: ThreadId) => + Ref.update(stateRef, (state) => { + const next = new Map(state); + const existingThreadMap = next.get(provider); + if (!existingThreadMap) { + return state; + } + const threadMap = new Map(existingThreadMap); + threadMap.delete(threadId); + if (threadMap.size === 0) { + next.delete(provider); + } else { + next.set(provider, threadMap); + } + return next; + }); + + const setThreadUsage = ( + provider: ProviderDriverKind, + threadId: ThreadId, + usage: ServerProviderUsageLimits, + updatedAtMs: number, + ) => + Ref.update(stateRef, (state) => { + const next = new Map(state); + const threadMap = new Map(next.get(provider) ?? []); + next.set(provider, threadMap); + threadMap.set(threadId, { usage, updatedAtMs }); + return next; + }); + const service: ProviderUsageStateShape = { get: (provider) => Ref.get(stateRef).pipe( @@ -101,51 +134,49 @@ export const ProviderUsageStateLive = Layer.effect( yield* Stream.runForEach(providerService.streamEvents, (event) => Effect.gen(function* () { - if (event.provider !== "cursor") { + if (event.type === "session.started" || event.type === "session.exited") { + yield* clearThreadUsage(event.provider, event.threadId); return; } - if (event.type === "session.started" || event.type === "session.exited") { - yield* Ref.update(stateRef, (state) => { - const next = new Map(state); - const existingThreadMap = next.get(CURSOR_DRIVER); - if (existingThreadMap) { - const threadMap = new Map(existingThreadMap); - next.set(CURSOR_DRIVER, threadMap); - threadMap.delete(event.threadId); - if (threadMap.size === 0) { - next.delete(CURSOR_DRIVER); - } - } - return next; - }); + if (event.provider === "cursor" && event.type === "thread.token-usage.updated") { + const usage = toCursorUsageLimits(event); + if (usage === undefined) { + return; + } + + yield* setThreadUsage( + CURSOR_DRIVER, + event.threadId, + usage, + Date.parse(event.createdAt) || Date.now(), + ); return; } - if (event.type !== "thread.token-usage.updated") { + if (event.provider !== "claudeAgent" || event.type !== "account.rate-limits.updated") { return; } - const usage = toCursorUsageLimits(event); + const usage = parseClaudeRuntimeUsageLimits({ + checkedAt: event.createdAt, + rateLimits: + typeof event.payload === "object" && + event.payload !== null && + "rateLimits" in event.payload + ? (event.payload as { readonly rateLimits?: unknown }).rateLimits + : undefined, + }); if (usage === undefined) { return; } - yield* Ref.update(stateRef, (state) => { - const next = new Map(state); - let threadMap = next.get(CURSOR_DRIVER); - if (!threadMap) { - threadMap = new Map(); - } else { - threadMap = new Map(threadMap); - } - next.set(CURSOR_DRIVER, threadMap); - threadMap.set(event.threadId, { - usage, - updatedAtMs: Date.parse(event.createdAt) || Date.now(), - }); - return next; - }); + yield* setThreadUsage( + CLAUDE_DRIVER, + event.threadId, + usage, + Date.parse(event.createdAt) || Date.now(), + ); }), ).pipe(Effect.forkScoped); diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index 70d562ba7d9..100e77d6335 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -1,36 +1,45 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as Effect from "effect/Effect"; -type Disposable = { readonly dispose: () => void }; +import type { PtyAdapterShape, PtyProcess } from "../terminal/Services/PTY.ts"; -class MockPtyChild { +class MockPtyChild implements PtyProcess { public readonly writes: string[] = []; public readonly kill = vi.fn(); private readonly dataListeners = new Set<(data: string) => void>(); - private readonly exitListeners = new Set<() => void>(); + private readonly exitListeners = new Set< + (event: { exitCode: number; signal: number | null }) => void + >(); - public onData(listener: (data: string) => void): Disposable { + public get pid(): number { + return 12345; + } + + public write(data: string): void { + this.writes.push(data); + } + + public resize(_cols: number, _rows: number): void { + // no-op + } + + public onData(listener: (data: string) => void): () => void { this.dataListeners.add(listener); - return { - dispose: () => { - this.dataListeners.delete(listener); - }, + return () => { + this.dataListeners.delete(listener); }; } - public onExit(listener: () => void): Disposable { + public onExit( + listener: (event: { exitCode: number; signal: number | null }) => void, + ): () => void { this.exitListeners.add(listener); - return { - dispose: () => { - this.exitListeners.delete(listener); - }, + return () => { + this.exitListeners.delete(listener); }; } - public write(data: string): void { - this.writes.push(data); - } - public emitData(data: string): void { for (const listener of this.dataListeners) { listener(data); @@ -39,41 +48,31 @@ class MockPtyChild { public emitExit(): void { for (const listener of this.exitListeners) { - listener(); + listener({ exitCode: 0, signal: null }); } } } -const spawnMock = vi.fn< - (file: string, args?: readonly string[], options?: Record) => MockPtyChild ->(() => new MockPtyChild()); - -vi.mock("node-pty", () => ({ - spawn: spawnMock, -})); +function makeMockPtyAdapter(child: MockPtyChild): PtyAdapterShape { + return { + spawn: () => { + child.writes.length = 0; + child.kill.mockClear(); + return Effect.succeed(child); + }, + }; +} import { + parseClaudeRuntimeUsageLimits, parseClaudeUsageLimitsOutput, probeClaudeUsageLimits, shouldRequestClaudeUsageFallback, } from "./claudeUsageProbe.ts"; -async function latestSpawnedChild(): Promise { - // Wait for dynamic import to resolve and spawn to be called - await vi.waitFor(() => { - const result = spawnMock.mock.results.at(-1)?.value; - if (!result) { - throw new Error("Expected node-pty spawn to be called."); - } - return result; - }); - return spawnMock.mock.results.at(-1)!.value as MockPtyChild; -} - describe("claudeUsageProbe", () => { beforeEach(() => { vi.useFakeTimers(); - spawnMock.mockClear(); }); afterEach(() => { @@ -167,6 +166,52 @@ describe("claudeUsageProbe", () => { }); }); + it("parses runtime Claude rate limit telemetry when utilization is present", () => { + expect( + parseClaudeRuntimeUsageLimits({ + checkedAt: "2026-04-17T10:00:00.000Z", + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "five_hour", + utilization: 37, + resetsAt: 1776448800, + }, + }, + }), + ).toEqual({ + source: "claudeStatusProbe", + available: true, + checkedAt: "2026-04-17T10:00:00.000Z", + windows: [ + { + kind: "session", + label: "Session", + usedPercent: 37, + windowDurationMins: 300, + resetsAt: "2026-04-17T18:00:00.000Z", + }, + ], + }); + }); + + it("ignores runtime Claude telemetry when utilization is missing", () => { + expect( + parseClaudeRuntimeUsageLimits({ + checkedAt: "2026-04-17T10:00:00.000Z", + rateLimits: { + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "seven_day_opus", + resetsAt: 1776448800, + }, + }, + }), + ).toBeUndefined(); + }); + it("requests the /usage fallback for short unavailable status output", () => { expect( shouldRequestClaudeUsageFallback({ @@ -195,13 +240,24 @@ describe("claudeUsageProbe", () => { }); it("triggers /usage fallback when /status remains quiet", async () => { - const probePromise = probeClaudeUsageLimits({ - binaryPath: "claude", - cwd: "/tmp", - checkedAt: "2026-04-17T10:00:00.000Z", + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + ), + ); + + await vi.waitFor(() => { + if (child.writes.length === 0) { + throw new Error("Expected PTY spawn and /status write to have been called."); + } }); - - const child = await latestSpawnedChild(); expect(child.writes).toEqual(["/status\r"]); await vi.advanceTimersByTimeAsync(150); @@ -213,13 +269,24 @@ describe("claudeUsageProbe", () => { }); it("triggers /usage fallback for short non-empty status output", async () => { - const probePromise = probeClaudeUsageLimits({ - binaryPath: "claude", - cwd: "/tmp", - checkedAt: "2026-04-17T10:00:00.000Z", + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + ), + ); + + await vi.waitFor(() => { + if (child.writes.length === 0) { + throw new Error("Expected PTY spawn and /status write to have been called."); + } }); - - const child = await latestSpawnedChild(); child.emitData("Authenticated as Claude Max\n"); await vi.advanceTimersByTimeAsync(150); @@ -231,13 +298,24 @@ describe("claudeUsageProbe", () => { }); it("skips /usage fallback when /status already returns usable quota output", async () => { - const probePromise = probeClaudeUsageLimits({ - binaryPath: "claude", - cwd: "/tmp", - checkedAt: "2026-04-17T10:00:00.000Z", + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + ), + ); + + await vi.waitFor(() => { + if (child.writes.length === 0) { + throw new Error("Expected PTY spawn and /status write to have been called."); + } }); - - const child = await latestSpawnedChild(); child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); const result = await probePromise; @@ -246,13 +324,24 @@ describe("claudeUsageProbe", () => { }); it("times out cleanly when neither /status nor /usage yields usable quota data", async () => { - const probePromise = probeClaudeUsageLimits({ - binaryPath: "claude", - cwd: "/tmp", - checkedAt: "2026-04-17T10:00:00.000Z", + const child = new MockPtyChild(); + const ptyAdapter = makeMockPtyAdapter(child); + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + ), + ); + + await vi.waitFor(() => { + if (child.writes.length === 0) { + throw new Error("Expected PTY spawn and /status write to have been called."); + } }); - - const child = await latestSpawnedChild(); await vi.advanceTimersByTimeAsync(150); expect(child.writes).toEqual(["/status\r", "/usage\r"]); diff --git a/apps/server/src/provider/claudeUsageProbe.ts b/apps/server/src/provider/claudeUsageProbe.ts index 090451bb148..1b5a1aa3e08 100644 --- a/apps/server/src/provider/claudeUsageProbe.ts +++ b/apps/server/src/provider/claudeUsageProbe.ts @@ -1,4 +1,6 @@ import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import { Effect } from "effect"; +import type { PtyAdapterShape, PtyProcess } from "../terminal/Services/PTY.ts"; import { makeUnavailableUsageLimits, makeUsageLimitsSnapshot } from "./providerUsageLimits.ts"; const CLAUDE_USAGE_PROBE_TIMEOUT_MS = 4_000; @@ -7,13 +9,87 @@ const ANSI_PATTERN = // Matches common CSI / OSC ANSI escape sequences. // eslint-disable-next-line no-control-regex /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g; -let nodePtyModulePromise: Promise | undefined; export interface ClaudeUsageProbeResult { readonly usageLimits: ServerProviderUsageLimits; readonly rawOutput: string; } +export interface ClaudeUsageProbeInput { + readonly binaryPath: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly checkedAt: string; + readonly environment?: NodeJS.ProcessEnv; +} + +function readObjectRecord(value: unknown): Readonly> | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readRateLimitDurationMins(value: unknown): number | undefined { + switch (value) { + case "five_hour": + return 5 * 60; + case "seven_day": + case "seven_day_opus": + case "seven_day_sonnet": + return 7 * 24 * 60; + default: + return undefined; + } +} + +function toRateLimitResetTimestamp(value: unknown): string | undefined { + const timestampSeconds = readNumber(value); + if (timestampSeconds === undefined) { + return undefined; + } + + const parsed = new Date(timestampSeconds * 1000); + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +} + +export function parseClaudeRuntimeUsageLimits(input: { + readonly checkedAt: string; + readonly rateLimits: unknown; +}): ServerProviderUsageLimits | undefined { + const eventRecord = readObjectRecord(input.rateLimits); + const rateLimitInfo = + readObjectRecord(eventRecord?.rate_limit_info) ?? readObjectRecord(input.rateLimits); + if (!rateLimitInfo) { + return undefined; + } + + const usedPercent = readNumber(rateLimitInfo.utilization); + const windowDurationMins = readRateLimitDurationMins(rateLimitInfo.rateLimitType); + if (usedPercent === undefined || windowDurationMins === undefined) { + return undefined; + } + + const resetsAt = toRateLimitResetTimestamp(rateLimitInfo.resetsAt); + + return makeUsageLimitsSnapshot({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + windows: [ + { + label: windowDurationMins === 5 * 60 ? "Session" : "Weekly", + usedPercent, + windowDurationMins, + ...(resetsAt === undefined ? {} : { resetsAt }), + }, + ], + unavailableReason: "Usage limits unavailable for this Claude account.", + }); +} + export function shouldRequestClaudeUsageFallback(input: { readonly output: string; readonly checkedAt: string; @@ -31,17 +107,6 @@ function stripAnsi(value: string): string { return value.replaceAll(ANSI_PATTERN, ""); } -async function getNodePtyModule(): Promise { - if (!nodePtyModulePromise) { - nodePtyModulePromise = import("node-pty").catch((error: unknown) => { - nodePtyModulePromise = undefined; - throw error; - }); - } - - return await nodePtyModulePromise; -} - function parsePercent(value: string | undefined): number | undefined { if (!value) return undefined; const parsed = Number.parseFloat(value); @@ -72,11 +137,15 @@ function detectClaudeUsageWindowKind(value: string): "session" | "weekly" | unde function extractResetTimestamp(value: string): string | undefined { const resetMatch = value.match(/\breset(?:s|ting)?(?:\s+(?:at|on|in))?[:\s-]*([^\n.;]+)/i); - const candidate = resetMatch?.[1] + const rawCandidate = resetMatch?.[1] ?.trim() .replace(/\s+/g, " ") .replace(/\b(?:local time|your time|time)\b.*$/i, "") .trim(); + const isoCandidate = rawCandidate?.match( + /\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:z|[+-]\d{2}:?\d{2})\b/i, + )?.[0]; + const candidate = isoCandidate ?? rawCandidate; if (!candidate) return undefined; if (/\b(?:today|tomorrow|tonight|next)\b/i.test(candidate)) { return undefined; @@ -186,32 +255,19 @@ export function parseClaudeUsageLimitsOutput(input: { }); } -export async function probeClaudeUsageLimits(input: { - readonly binaryPath: string; - readonly launchArgs?: string; - readonly cwd: string; - readonly checkedAt: string; - readonly environment?: NodeJS.ProcessEnv; -}): Promise { - const nodePty = await getNodePtyModule(); - const probeArgs = [ - ...(input.launchArgs?.trim().split(/\s+/).filter(Boolean) ?? []), - "--permission-mode", - "plan", - ]; - - return await new Promise((resolve) => { - const child = nodePty.spawn(input.binaryPath, probeArgs, { - cwd: input.cwd, - cols: 120, - rows: 40, - env: input.environment ?? process.env, - name: process.platform === "win32" ? "xterm-color" : "xterm-256color", - }); +function runProbeLoop( + child: PtyProcess, + input: ClaudeUsageProbeInput, +): Promise { + return new Promise((resolve) => { let rawOutput = ""; - let sentFallback = false; let settled = false; let fallbackTimer: ReturnType | undefined; + let sentFallback = false; + + const timeout = setTimeout(() => { + finish(); + }, CLAUDE_USAGE_PROBE_TIMEOUT_MS); const scheduleFallback = () => { if (sentFallback || settled) { @@ -233,8 +289,8 @@ export async function probeClaudeUsageLimits(input: { if (fallbackTimer) { clearTimeout(fallbackTimer); } - offData.dispose(); - offExit.dispose(); + offData(); + offExit(); try { child.kill(); } catch { @@ -265,10 +321,6 @@ export async function probeClaudeUsageLimits(input: { child.write("/usage\r"); }; - const timeout = setTimeout(() => { - finish(); - }, CLAUDE_USAGE_PROBE_TIMEOUT_MS); - const offData = child.onData((data) => { rawOutput += data; const parsed = parseClaudeUsageLimitsOutput({ @@ -292,3 +344,39 @@ export async function probeClaudeUsageLimits(input: { scheduleFallback(); }); } + +export function probeClaudeUsageLimits( + input: ClaudeUsageProbeInput, + ptyAdapter: PtyAdapterShape, +): Effect.Effect { + const probeArgs = [ + ...(input.launchArgs?.trim().split(/\s+/).filter(Boolean) ?? []), + "--permission-mode", + "plan", + ]; + + return Effect.promise(async () => { + const spawnResult = ptyAdapter.spawn({ + shell: input.binaryPath, + args: probeArgs, + cwd: input.cwd, + cols: 120, + rows: 40, + env: input.environment ?? process.env, + }); + + const child = await Effect.runPromise(spawnResult).catch(() => null); + if (!child) { + return { + usageLimits: makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Failed to spawn Claude process for usage probe.", + }), + rawOutput: "", + }; + } + + return runProbeLoop(child, input); + }); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ed0066b64d1..36af229d4c8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -487,7 +487,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provideMerge(vcsDriverRegistryLayer), ); - const _workspaceAndProjectServicesLayer = Layer.mergeAll( + const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, workspaceEntriesLayer, WorkspaceFileSystemLive.pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5ab3b206a98..f34afee6110 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -206,7 +206,10 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const TerminalLayerLive = Layer.mergeAll( + PtyAdapterLive, + TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)), +); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), From 44c5772c3f04d21fb1cc05ecee865453e2a12eed Mon Sep 17 00:00:00 2001 From: aditya mer Date: Mon, 4 May 2026 12:31:24 +0530 Subject: [PATCH 32/59] refactor(migrations): streamline auth_sessions migration by removing unused checks for pairing_links --- .../021_AuthSessionClientMetadata.ts | 99 +++++++++---------- .../029_AuthCompatibilityColumns.ts | 56 +---------- 2 files changed, 45 insertions(+), 110 deletions(-) diff --git a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts index c4fc91b73c5..3b387fdcfd2 100644 --- a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts +++ b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts @@ -4,70 +4,59 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const existingTables = yield* sql<{ readonly name: string }>` - SELECT name - FROM sqlite_master - WHERE type = 'table' - AND name IN ('auth_pairing_links', 'auth_sessions') + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) `; - const tableNames = new Set(existingTables.map((table) => table.name)); - if (tableNames.has("auth_pairing_links")) { - const pairingLinkColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_pairing_links) + if (!pairingLinkColumns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN label TEXT `; - if (!pairingLinkColumns.some((column) => column.name === "label")) { - yield* sql` - ALTER TABLE auth_pairing_links - ADD COLUMN label TEXT - `; - } } - if (tableNames.has("auth_sessions")) { - const sessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_sessions) - `; + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; - if (!sessionColumns.some((column) => column.name === "client_label")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_label TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_label")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_label TEXT + `; + } - if (!sessionColumns.some((column) => column.name === "client_ip_address")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_ip_address TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_ip_address")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_ip_address TEXT + `; + } - if (!sessionColumns.some((column) => column.name === "client_user_agent")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_user_agent TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_user_agent")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_user_agent TEXT + `; + } - if (!sessionColumns.some((column) => column.name === "client_device_type")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' - `; - } + if (!sessionColumns.some((column) => column.name === "client_device_type")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' + `; + } - if (!sessionColumns.some((column) => column.name === "client_os")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_os TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_os")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_os TEXT + `; + } - if (!sessionColumns.some((column) => column.name === "client_browser")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_browser TEXT - `; - } + if (!sessionColumns.some((column) => column.name === "client_browser")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_browser TEXT + `; } }); diff --git a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts index 0866db0e05c..2db9bf99837 100644 --- a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts +++ b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts @@ -8,69 +8,15 @@ export default Effect.gen(function* () { SELECT name FROM sqlite_master WHERE type = 'table' - AND name IN ('auth_pairing_links', 'auth_sessions') + AND name = 'auth_sessions' `; const tableNames = new Set(existingTables.map((table) => table.name)); - if (tableNames.has("auth_pairing_links")) { - const pairingLinkColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_pairing_links) - `; - if (!pairingLinkColumns.some((column) => column.name === "label")) { - yield* sql` - ALTER TABLE auth_pairing_links - ADD COLUMN label TEXT - `; - } - } - if (tableNames.has("auth_sessions")) { const sessionColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(auth_sessions) `; - if (!sessionColumns.some((column) => column.name === "client_label")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_label TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_ip_address")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_ip_address TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_user_agent")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_user_agent TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_device_type")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' - `; - } - - if (!sessionColumns.some((column) => column.name === "client_os")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_os TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_browser")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_browser TEXT - `; - } - if (!sessionColumns.some((column) => column.name === "last_connected_at")) { yield* sql` ALTER TABLE auth_sessions From 89252d46298f4b8faa4ddbde039ee4e0a7bf9aed Mon Sep 17 00:00:00 2001 From: aditya mer Date: Wed, 6 May 2026 18:21:11 +0530 Subject: [PATCH 33/59] feat(provider): add timestamp for usage limit checks and improve key generation in settings panel --- .../src/provider/Layers/ClaudeProvider.ts | 119 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 103 +++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 3b91a2dda5f..f294d085df7 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -736,3 +736,122 @@ export const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): Serve }; export { probeClaudeCapabilities }; + +export const ClaudeProviderLive = Layer.effect( + ClaudeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const usageProbeStateRef = yield* Ref.make( + new Map(), + ); + const usageProbeTtlMs = 5 * 60 * 1000; + + const subscriptionProbeCache = yield* Cache.make({ + capacity: 1, + timeToLive: Duration.minutes(5), + lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), + }); + const refreshUsageProbe = (key: string, binaryPath: string, launchArgs: string) => + Effect.gen(function* () { + yield* Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + const existing = next.get(key); + next.set(key, { + rawOutput: existing?.rawOutput ?? "", + fetchedAtMs: existing?.fetchedAtMs ?? 0, + inFlight: true, + }); + return next; + }); + + const checkedAt = new Date().toISOString(); + const rawOutput = yield* Effect.tryPromise(() => + probeClaudeUsageLimits({ + binaryPath, + launchArgs, + cwd: process.cwd(), + checkedAt, + }), + ).pipe( + Effect.map((result) => result.rawOutput), + Effect.orElseSucceed(() => ""), + ); + + yield* Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + next.set(key, { + rawOutput, + fetchedAtMs: Date.now(), + inFlight: false, + }); + return next; + }); + }).pipe( + Effect.ensuring( + Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + const existing = next.get(key); + if (existing) { + next.set(key, { ...existing, inFlight: false }); + } + return next; + }), + ), + ); + + const checkProvider = checkClaudeProviderStatus( + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.subscriptionType), + ), + (binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath).pipe( + Effect.map((probe) => probe?.slashCommands), + ), + (input) => + Effect.gen(function* () { + const key = JSON.stringify([input.binaryPath, input.launchArgs]); + const entry = (yield* Ref.get(usageProbeStateRef)).get(key); + const isFresh = entry !== undefined && Date.now() - entry.fetchedAtMs < usageProbeTtlMs; + + if ((!entry || !isFresh) && !entry?.inFlight) { + yield* Effect.sync(() => { + void Effect.runPromiseExit( + refreshUsageProbe(key, input.binaryPath, input.launchArgs), + ); + }); + } + + if (!entry) { + return makeUnavailableUsageLimits({ + source: "claudeStatusProbe", + checkedAt: input.checkedAt, + reason: "Usage limits are still loading for this Claude account.", + }); + } + + return parseClaudeUsageLimitsOutput({ + output: entry.rawOutput, + checkedAt: input.checkedAt, + }); + }), + ).pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.claudeAgent), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.claudeAgent), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingClaudeProvider, + checkProvider, + }); + }), +); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ee75fba5d06..2678037780e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -145,6 +145,109 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } ); } +function getUsageMeterToneClass(usedPercent: number): string { + if (usedPercent >= 90) return "bg-destructive"; + if (usedPercent >= 70) return "bg-warning"; + return "bg-foreground/88"; +} + +function getUsageRemainingLabel(usedPercent: number): string { + const remainingPercent = Math.max(0, Math.min(100, 100 - Math.round(usedPercent))); + return `${remainingPercent}% remaining`; +} + +function getUsageTrackClass(usedPercent: number): string { + if (usedPercent >= 90) return "bg-destructive/12"; + if (usedPercent >= 70) return "bg-warning/12"; + return "bg-white/6 dark:bg-white/6"; +} + +function getUsageResetLabel(resetsAt: string | undefined): string | null { + if (!resetsAt) return null; + const diffMs = new Date(resetsAt).getTime() - Date.now(); + if (!Number.isFinite(diffMs) || diffMs <= 0) { + return null; + } + const dayMs = 24 * 60 * 60 * 1000; + if (diffMs >= dayMs && diffMs < dayMs * 2) { + return "Resets tomorrow"; + } + return `Resets in ${formatRelativeTimeUntilLabel(resetsAt).replace(/ left$/, "")}`; +} + +function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | undefined }) { + useRelativeTimeTick(); + + if (!provider || !provider.enabled || !provider.installed || !provider.usageLimits) { + return null; + } + + if (!provider.usageLimits.available) { + return ( +

+ {provider.usageLimits.reason ?? "Usage limits unavailable for this account"} +

+ ); + } + + return ( +
+ {provider.usageLimits.windows.map((window) => { + const percentageLabel = `${Math.round(window.usedPercent)}%`; + const resetLabel = getUsageResetLabel(window.resetsAt); + const normalizedWidth = Math.max(0, Math.min(100, window.usedPercent)); + const remainingLabel = getUsageRemainingLabel(window.usedPercent); + + return ( +
+
+ + {window.label} limit + + {remainingLabel} +
+
+
+
+ {resetLabel ? ( +

{resetLabel}

+ ) : null} +
+ ); + })} +
+ ); +} + +function ProviderCardShell({ children, expanded }: { children: ReactNode; expanded: boolean }) { + return ( +
+
+ {children} +
+ ); +} + function AboutVersionTitle() { return ( From cb35f3e7ef8e990f94a3b15dfb2381200d28abcb Mon Sep 17 00:00:00 2001 From: aditya mer Date: Wed, 6 May 2026 18:22:46 +0530 Subject: [PATCH 34/59] feat(provider): update latestSpawnedChild to async and improve windowKindFromDuration logic --- .../src/provider/claudeUsageProbe.test.ts | 24 ++++--------------- .../components/settings/SettingsPanels.tsx | 2 +- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index 100e77d6335..cb433ed0215 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -253,11 +253,7 @@ describe("claudeUsageProbe", () => { ), ); - await vi.waitFor(() => { - if (child.writes.length === 0) { - throw new Error("Expected PTY spawn and /status write to have been called."); - } - }); + const child = await latestSpawnedChild(); expect(child.writes).toEqual(["/status\r"]); await vi.advanceTimersByTimeAsync(150); @@ -282,11 +278,7 @@ describe("claudeUsageProbe", () => { ), ); - await vi.waitFor(() => { - if (child.writes.length === 0) { - throw new Error("Expected PTY spawn and /status write to have been called."); - } - }); + const child = await latestSpawnedChild(); child.emitData("Authenticated as Claude Max\n"); await vi.advanceTimersByTimeAsync(150); @@ -311,11 +303,7 @@ describe("claudeUsageProbe", () => { ), ); - await vi.waitFor(() => { - if (child.writes.length === 0) { - throw new Error("Expected PTY spawn and /status write to have been called."); - } - }); + const child = await latestSpawnedChild(); child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); const result = await probePromise; @@ -337,11 +325,7 @@ describe("claudeUsageProbe", () => { ), ); - await vi.waitFor(() => { - if (child.writes.length === 0) { - throw new Error("Expected PTY spawn and /status write to have been called."); - } - }); + const child = await latestSpawnedChild(); await vi.advanceTimersByTimeAsync(150); expect(child.writes).toEqual(["/status\r", "/usage\r"]); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2678037780e..bad3b90b020 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -159,7 +159,7 @@ function getUsageRemainingLabel(usedPercent: number): string { function getUsageTrackClass(usedPercent: number): string { if (usedPercent >= 90) return "bg-destructive/12"; if (usedPercent >= 70) return "bg-warning/12"; - return "bg-white/6 dark:bg-white/6"; + return "bg-black/5 dark:bg-white/6"; } function getUsageResetLabel(resetsAt: string | undefined): string | null { From d1ecb3a2e34b7853304673f3236d218922e9b322 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 15:44:45 +0530 Subject: [PATCH 35/59] Enhance usage reset label formatting and update styles for provider cards --- .../src/components/settings/SettingsPanels.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bad3b90b020..270a1899b7d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -172,7 +172,11 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (diffMs >= dayMs && diffMs < dayMs * 2) { return "Resets tomorrow"; } - return `Resets in ${formatRelativeTimeUntilLabel(resetsAt).replace(/ left$/, "")}`; + const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); + if (relativeLabel === "Soon") { + return "Resets soon"; + } + return `Resets in ${relativeLabel.replace(/ left$/, "")}`; } function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | undefined }) { @@ -200,7 +204,7 @@ function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | und return (
@@ -238,11 +242,12 @@ function ProviderCardShell({ children, expanded }: { children: ReactNode; expand return (
-
+
{children}
); From 5c20bed073e5a73faf17bd0dd53e3173a9935288 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Wed, 6 May 2026 18:26:14 +0530 Subject: [PATCH 36/59] Refactor ClaudeProvider and usage probe to enhance error handling and module loading --- apps/server/src/provider/Layers/ClaudeProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f294d085df7..b508b127009 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -823,7 +823,7 @@ export const ClaudeProviderLive = Layer.effect( }); } - if (!entry) { + if (!entry || (entry.inFlight && entry.rawOutput.trim().length === 0)) { return makeUnavailableUsageLimits({ source: "claudeStatusProbe", checkedAt: input.checkedAt, From d024d7e2558afc93e7b43d2bf811c3965107ef89 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 19:56:37 +0530 Subject: [PATCH 37/59] Refactor ClaudeProvider to improve variable naming and enhance clarity in usage probe logic --- apps/server/src/provider/Layers/ClaudeProvider.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b508b127009..3a2a89aa1cd 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -812,10 +812,11 @@ export const ClaudeProviderLive = Layer.effect( (input) => Effect.gen(function* () { const key = JSON.stringify([input.binaryPath, input.launchArgs]); - const entry = (yield* Ref.get(usageProbeStateRef)).get(key); - const isFresh = entry !== undefined && Date.now() - entry.fetchedAtMs < usageProbeTtlMs; + const currentEntry = (yield* Ref.get(usageProbeStateRef)).get(key); + const isFresh = + currentEntry !== undefined && Date.now() - currentEntry.fetchedAtMs < usageProbeTtlMs; - if ((!entry || !isFresh) && !entry?.inFlight) { + if ((!currentEntry || !isFresh) && !currentEntry?.inFlight) { yield* Effect.sync(() => { void Effect.runPromiseExit( refreshUsageProbe(key, input.binaryPath, input.launchArgs), @@ -823,7 +824,9 @@ export const ClaudeProviderLive = Layer.effect( }); } - if (!entry || (entry.inFlight && entry.rawOutput.trim().length === 0)) { + const latestEntry = (yield* Ref.get(usageProbeStateRef)).get(key); + + if (!latestEntry || (latestEntry.inFlight && latestEntry.rawOutput.trim().length === 0)) { return makeUnavailableUsageLimits({ source: "claudeStatusProbe", checkedAt: input.checkedAt, @@ -832,7 +835,7 @@ export const ClaudeProviderLive = Layer.effect( } return parseClaudeUsageLimitsOutput({ - output: entry.rawOutput, + output: latestEntry.rawOutput, checkedAt: input.checkedAt, }); }), From d4e8bd15a5cad59c9ad08ebef7ba007aac4a6f43 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 21:04:29 +0530 Subject: [PATCH 38/59] feat: add provider usage limits schema and server-side persistence --- apps/server/src/provider/claudeUsageProbe.test.ts | 4 ---- apps/server/src/server.ts | 13 +++++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index cb433ed0215..38585d2a7df 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -253,7 +253,6 @@ describe("claudeUsageProbe", () => { ), ); - const child = await latestSpawnedChild(); expect(child.writes).toEqual(["/status\r"]); await vi.advanceTimersByTimeAsync(150); @@ -278,7 +277,6 @@ describe("claudeUsageProbe", () => { ), ); - const child = await latestSpawnedChild(); child.emitData("Authenticated as Claude Max\n"); await vi.advanceTimersByTimeAsync(150); @@ -303,7 +301,6 @@ describe("claudeUsageProbe", () => { ), ); - const child = await latestSpawnedChild(); child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); const result = await probePromise; @@ -325,7 +322,6 @@ describe("claudeUsageProbe", () => { ), ); - const child = await latestSpawnedChild(); await vi.advanceTimersByTimeAsync(150); expect(child.writes).toEqual(["/status\r", "/usage\r"]); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c1a6ad271bb..c0ee0d81bf3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,7 +20,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; -import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderServiceLive, makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import { ProviderUsageStateLive } from "./provider/Layers/ProviderUsageState.ts"; import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; @@ -239,9 +239,14 @@ const AuthLayerLive = ServerAuthLive.pipe( Layer.provide(ServerSecretStoreLive), ); -const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( - Layer.provideMerge(ProviderLayerLive), - Layer.provideMerge(OrchestrationLayerLive), +const ProviderRuntimeLayerLive = Layer.mergeAll( + ProviderLayerLive, + ProviderUsageStateLive.pipe(Layer.provide(ProviderLayerLive)), + ProviderSessionReaperLive.pipe( + Layer.provideMerge(OrchestrationLayerLive), + Layer.provide(ProviderLayerLive), + ), + OrchestrationLayerLive, ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( From ab7694ef2a2956c693994f3ebd0c99e1814403b9 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 18 Apr 2026 21:04:49 +0530 Subject: [PATCH 39/59] feat: render usage limits in Settings UI with progress bars --- apps/web/src/components/settings/SettingsPanels.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 270a1899b7d..e67e572ab59 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -189,7 +189,7 @@ function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | und if (!provider.usageLimits.available) { return (

- {provider.usageLimits.reason ?? "Usage limits unavailable for this account"} + {provider.usageLimits?.reason ?? "Unable to fetch usage"}

); } @@ -204,7 +204,7 @@ function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | und return (
From 281dfe32740e44b3778bcd199db4c226636748a6 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 10:06:55 +0530 Subject: [PATCH 40/59] fix: resolve 5 code issues from Cursor Bugbot review + lint warnings - Fix migration ID collision (AuthAccessManagementCompat vs CanonicalizeModelSelectionOptions) - Fix Claude usage probe race condition causing duplicate PTY spawns - Fix Claude usage cache key leak by using single key per provider - Fix "Resets tomorrow" label to use local date comparison instead of fixed ms windows - Fix Codex rate-limits catch to preserve unexpected errors instead of swallowing them - Fix 12 lint warnings: map spreads, function scoping, react-hooks deps, unused vars --- ...s.ts => 027_CanonicalizeModelSelectionOptions.ts} | 0 apps/server/src/provider/Layers/ClaudeProvider.ts | 12 +++++++++++- apps/server/src/server.test.ts | 2 +- apps/web/src/components/settings/SettingsPanels.tsx | 10 ++++++++-- 4 files changed, 20 insertions(+), 4 deletions(-) rename apps/server/src/persistence/Migrations/{026_CanonicalizeModelSelectionOptions.ts => 027_CanonicalizeModelSelectionOptions.ts} (100%) diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts similarity index 100% rename from apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts rename to apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 3a2a89aa1cd..2ec1918e0ae 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -811,12 +811,22 @@ export const ClaudeProviderLive = Layer.effect( ), (input) => Effect.gen(function* () { - const key = JSON.stringify([input.binaryPath, input.launchArgs]); + const key = "claude-usage-probe"; const currentEntry = (yield* Ref.get(usageProbeStateRef)).get(key); const isFresh = currentEntry !== undefined && Date.now() - currentEntry.fetchedAtMs < usageProbeTtlMs; if ((!currentEntry || !isFresh) && !currentEntry?.inFlight) { + yield* Ref.update(usageProbeStateRef, (current) => { + const next = new Map(current); + const existing = next.get(key); + next.set(key, { + rawOutput: existing?.rawOutput ?? "", + fetchedAtMs: existing?.fetchedAtMs ?? 0, + inFlight: true, + }); + return next; + }); yield* Effect.sync(() => { void Effect.runPromiseExit( refreshUsageProbe(key, input.binaryPath, input.launchArgs), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 32261dd618b..dc55dfa3cbf 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -479,7 +479,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provideMerge(vcsDriverRegistryLayer), ); - const workspaceAndProjectServicesLayer = Layer.mergeAll( + const _workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, workspaceEntriesLayer, WorkspaceFileSystemLive.pipe( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e67e572ab59..b8c7500949f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -168,8 +168,14 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (!Number.isFinite(diffMs) || diffMs <= 0) { return null; } - const dayMs = 24 * 60 * 60 * 1000; - if (diffMs >= dayMs && diffMs < dayMs * 2) { + const now = new Date(); + const resetDate = new Date(resetsAt); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const isResetTomorrow = + resetDate.toDateString() !== now.toDateString() && + resetDate.toDateString() === tomorrow.toDateString(); + if (isResetTomorrow) { return "Resets tomorrow"; } const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); From 6a5bbc85598a51508f5a2b176edd6de08030e493 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 10:36:46 +0530 Subject: [PATCH 41/59] feat: per-thread usage limits tracking + bug fixes - Track usage limits per-thread instead of globally in ProviderUsageState - Fix Claude usage probe to properly detect API key accounts vs usage windows - Fix provider status cache to use fallback usage limits when missing - Fix provider usage limits to preserve custom window labels - Reorder migrations 026/027 to fix migration execution order --- ...ctionOptions.ts => 026_CanonicalizeModelSelectionOptions.ts} | 0 apps/server/src/provider/providerUsageLimits.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/server/src/persistence/Migrations/{027_CanonicalizeModelSelectionOptions.ts => 026_CanonicalizeModelSelectionOptions.ts} (100%) diff --git a/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts similarity index 100% rename from apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionOptions.ts rename to apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.ts diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index 7a23ac0e9a2..b4aa57bd930 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -63,7 +63,7 @@ export function normalizeUsageWindows( return [ { kind, - label: kind === "session" ? "Session" : "Weekly", + label: window.label || (kind === "session" ? "Session" : "Weekly"), usedPercent: clampPercent(window.usedPercent), ...(window.resetsAt ? { resetsAt: window.resetsAt } : {}), ...(typeof window.windowDurationMins === "number" && From 6efe6aaf3fd7aa83665b82d5841108837b6455f0 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 11:52:03 +0530 Subject: [PATCH 42/59] refactor: enhance auth session and pairing link migrations - Simplify migration logic for adding columns to auth_sessions and auth_pairing_links. - Ensure columns are only added if they do not already exist. - Update migration files to improve readability and maintainability. --- apps/web/src/components/settings/SettingsPanels.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b8c7500949f..3d3a70408b3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -168,6 +168,13 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (!Number.isFinite(diffMs) || diffMs <= 0) { return null; } + const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); + if (relativeLabel === "Soon") { + return "Resets soon"; + } + if (diffMs < 6 * 60 * 60 * 1000) { + return `Resets in ${relativeLabel.replace(/ left$/, "")}`; + } const now = new Date(); const resetDate = new Date(resetsAt); const tomorrow = new Date(now); @@ -178,10 +185,6 @@ function getUsageResetLabel(resetsAt: string | undefined): string | null { if (isResetTomorrow) { return "Resets tomorrow"; } - const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); - if (relativeLabel === "Soon") { - return "Resets soon"; - } return `Resets in ${relativeLabel.replace(/ left$/, "")}`; } From 4fc02a3cf06c82ecf3226c6df024d3613242d6e7 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Tue, 28 Apr 2026 13:16:43 +0530 Subject: [PATCH 43/59] fix: usage state bugs - cache key collision, labeling, and asymmetric key handling - Fix Claude usage probe cache key collision: use binaryPath instead of hardcoded 'claude-usage-probe' key, preventing cross-contamination when switching between Claude installations/accounts - Fix Cursor usage labeling: change 'Session' to 'Context window' to accurately reflect that ACP usage_update events track context-window utilization (size/used tokens), not subscription quota - Fix ProviderUsageState asymmetric key handling: require explicit threadId in set() and remove 'global' key short-circuit in get(). This ensures per-thread entries are properly isolated and prevents future misuse --- apps/server/src/provider/Layers/ClaudeProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 2ec1918e0ae..963529347c2 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -811,7 +811,7 @@ export const ClaudeProviderLive = Layer.effect( ), (input) => Effect.gen(function* () { - const key = "claude-usage-probe"; + const key = input.binaryPath; const currentEntry = (yield* Ref.get(usageProbeStateRef)).get(key); const isFresh = currentEntry !== undefined && Date.now() - currentEntry.fetchedAtMs < usageProbeTtlMs; From 377deffae355795a7ac802f7620ba88f7e305687 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 30 Apr 2026 13:25:13 +0530 Subject: [PATCH 44/59] Improvement in provider usage limits UI --- .../027_028_ProviderInstanceIdColumns.test.ts | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts deleted file mode 100644 index 3233f5043af..00000000000 --- a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; - -const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); - -layer("027_028_ProviderInstanceIdColumns", (it) => { - it.effect("continues when provider_session_runtime was partially migrated", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* runMigrations({ toMigrationInclusive: 26 }); - yield* sql` - ALTER TABLE provider_session_runtime - ADD COLUMN provider_instance_id TEXT - `; - - yield* runMigrations({ toMigrationInclusive: 28 }); - - const migrations = yield* sql<{ - readonly migration_id: number; - readonly name: string; - }>` - SELECT migration_id, name - FROM effect_sql_migrations - WHERE migration_id IN (27, 28) - ORDER BY migration_id - `; - assert.deepStrictEqual(migrations, [ - { - migration_id: 27, - name: "ProviderSessionRuntimeInstanceId", - }, - { - migration_id: 28, - name: "ProjectionThreadSessionInstanceId", - }, - ]); - - const providerSessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(provider_session_runtime) - `; - assert.ok(providerSessionColumns.some((column) => column.name === "provider_instance_id")); - - const projectionThreadSessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(projection_thread_sessions) - `; - assert.ok( - projectionThreadSessionColumns.some((column) => column.name === "provider_instance_id"), - ); - - const providerSessionIndexes = yield* sql<{ readonly name: string }>` - PRAGMA index_list(provider_session_runtime) - `; - assert.ok( - providerSessionIndexes.some( - (index) => index.name === "idx_provider_session_runtime_instance", - ), - ); - - const projectionThreadSessionIndexes = yield* sql<{ readonly name: string }>` - PRAGMA index_list(projection_thread_sessions) - `; - assert.ok( - projectionThreadSessionIndexes.some( - (index) => index.name === "idx_projection_thread_sessions_instance", - ), - ); - }), - ); -}); From 5092e83f2cd1830a1d2d4428ba5b680804a4e0da Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 30 Apr 2026 13:42:25 +0530 Subject: [PATCH 45/59] fix(provider): pass environment to probeClaudeUsageLimits for improved usage tracking --- scratch/opencode-quota | 1 + scratch/opencode-quotas | 1 + 2 files changed, 2 insertions(+) create mode 160000 scratch/opencode-quota create mode 160000 scratch/opencode-quotas diff --git a/scratch/opencode-quota b/scratch/opencode-quota new file mode 160000 index 00000000000..1aeb06166da --- /dev/null +++ b/scratch/opencode-quota @@ -0,0 +1 @@ +Subproject commit 1aeb06166dae289ea774571017d130adfbc4a1b1 diff --git a/scratch/opencode-quotas b/scratch/opencode-quotas new file mode 160000 index 00000000000..1f3c6dd8c0f --- /dev/null +++ b/scratch/opencode-quotas @@ -0,0 +1 @@ +Subproject commit 1f3c6dd8c0fa6a5700cbb91f0438f6df5fe28cd5 From 5bb68561f15cdd9e6d96661a9e796c08da067ccf Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 30 Apr 2026 13:58:38 +0530 Subject: [PATCH 46/59] refactor: formalize Cursor ACP capability probe results using a tagged union and simplify usage limit label generation --- apps/server/src/provider/providerUsageLimits.ts | 2 +- scratch/opencode-quota | 1 - scratch/opencode-quotas | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) delete mode 160000 scratch/opencode-quota delete mode 160000 scratch/opencode-quotas diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index b4aa57bd930..7a23ac0e9a2 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -63,7 +63,7 @@ export function normalizeUsageWindows( return [ { kind, - label: window.label || (kind === "session" ? "Session" : "Weekly"), + label: kind === "session" ? "Session" : "Weekly", usedPercent: clampPercent(window.usedPercent), ...(window.resetsAt ? { resetsAt: window.resetsAt } : {}), ...(typeof window.windowDurationMins === "number" && diff --git a/scratch/opencode-quota b/scratch/opencode-quota deleted file mode 160000 index 1aeb06166da..00000000000 --- a/scratch/opencode-quota +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1aeb06166dae289ea774571017d130adfbc4a1b1 diff --git a/scratch/opencode-quotas b/scratch/opencode-quotas deleted file mode 160000 index 1f3c6dd8c0f..00000000000 --- a/scratch/opencode-quotas +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1f3c6dd8c0fa6a5700cbb91f0438f6df5fe28cd5 From 1a69aff01f4f77e4026906ccf4783d3e6689fb96 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 1 May 2026 15:26:24 +0530 Subject: [PATCH 47/59] Add ProviderUsageStateLive integration and improve usage bar styling --- apps/server/src/server.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c0ee0d81bf3..acc1899e40d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -148,6 +148,11 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderUsageStateLive), ); +const CheckpointingLayerLive = Layer.empty.pipe( + Layer.provideMerge(CheckpointDiffQueryLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), +); + const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); From 42e38487686b916a5bc3a896d46dca89ac236e6c Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sun, 3 May 2026 12:15:24 +0530 Subject: [PATCH 48/59] feat(tests): add migration tests for provider_instance_id column --- .../027_028_ProviderInstanceIdColumns.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts new file mode 100644 index 00000000000..3233f5043af --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -0,0 +1,74 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("027_028_ProviderInstanceIdColumns", (it) => { + it.effect("continues when provider_session_runtime was partially migrated", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 26 }); + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN provider_instance_id TEXT + `; + + yield* runMigrations({ toMigrationInclusive: 28 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id IN (27, 28) + ORDER BY migration_id + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 27, + name: "ProviderSessionRuntimeInstanceId", + }, + { + migration_id: 28, + name: "ProjectionThreadSessionInstanceId", + }, + ]); + + const providerSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(provider_session_runtime) + `; + assert.ok(providerSessionColumns.some((column) => column.name === "provider_instance_id")); + + const projectionThreadSessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionColumns.some((column) => column.name === "provider_instance_id"), + ); + + const providerSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(provider_session_runtime) + `; + assert.ok( + providerSessionIndexes.some( + (index) => index.name === "idx_provider_session_runtime_instance", + ), + ); + + const projectionThreadSessionIndexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_thread_sessions) + `; + assert.ok( + projectionThreadSessionIndexes.some( + (index) => index.name === "idx_projection_thread_sessions_instance", + ), + ); + }), + ); +}); From 184feb04df5cab96d69d884b6e4f4708425f9991 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Mon, 4 May 2026 12:16:13 +0530 Subject: [PATCH 49/59] feat(server): add provider usage limits with PTY adapter integration - Refactor claudeUsageProbe to use shared PtyAdapter instead of ad-hoc node-pty - Add parseClaudeRuntimeUsageLimits for SDK rate-limit event parsing - Add ProviderUsageState layer with tests - Integrate usage state into ClaudeDriver and ClaudeProvider - Add migration 029 for auth session compatibility columns (keep 021 intact) - Update server and tests for usage limits wiring --- .../029_AuthCompatibilityColumns.ts | 81 +++++++++++++++++++ apps/server/src/server.test.ts | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts diff --git a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts new file mode 100644 index 00000000000..0866db0e05c --- /dev/null +++ b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts @@ -0,0 +1,81 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const existingTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name IN ('auth_pairing_links', 'auth_sessions') + `; + const tableNames = new Set(existingTables.map((table) => table.name)); + + if (tableNames.has("auth_pairing_links")) { + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN label TEXT + `; + } + } + + if (tableNames.has("auth_sessions")) { + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!sessionColumns.some((column) => column.name === "client_label")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_label TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_ip_address")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_ip_address TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_user_agent")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_user_agent TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_device_type")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' + `; + } + + if (!sessionColumns.some((column) => column.name === "client_os")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_os TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_browser")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_browser TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "last_connected_at")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN last_connected_at TEXT + `; + } + } +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index dc55dfa3cbf..32261dd618b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -479,7 +479,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provideMerge(vcsDriverRegistryLayer), ); - const _workspaceAndProjectServicesLayer = Layer.mergeAll( + const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, workspaceEntriesLayer, WorkspaceFileSystemLive.pipe( From ec30593f391e23ece7db1f209686ce5bfa183343 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Mon, 4 May 2026 12:31:24 +0530 Subject: [PATCH 50/59] refactor(migrations): streamline auth_sessions migration by removing unused checks for pairing_links --- .../029_AuthCompatibilityColumns.ts | 56 +------------------ 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts index 0866db0e05c..2db9bf99837 100644 --- a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts +++ b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts @@ -8,69 +8,15 @@ export default Effect.gen(function* () { SELECT name FROM sqlite_master WHERE type = 'table' - AND name IN ('auth_pairing_links', 'auth_sessions') + AND name = 'auth_sessions' `; const tableNames = new Set(existingTables.map((table) => table.name)); - if (tableNames.has("auth_pairing_links")) { - const pairingLinkColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_pairing_links) - `; - if (!pairingLinkColumns.some((column) => column.name === "label")) { - yield* sql` - ALTER TABLE auth_pairing_links - ADD COLUMN label TEXT - `; - } - } - if (tableNames.has("auth_sessions")) { const sessionColumns = yield* sql<{ readonly name: string }>` PRAGMA table_info(auth_sessions) `; - if (!sessionColumns.some((column) => column.name === "client_label")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_label TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_ip_address")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_ip_address TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_user_agent")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_user_agent TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_device_type")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' - `; - } - - if (!sessionColumns.some((column) => column.name === "client_os")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_os TEXT - `; - } - - if (!sessionColumns.some((column) => column.name === "client_browser")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN client_browser TEXT - `; - } - if (!sessionColumns.some((column) => column.name === "last_connected_at")) { yield* sql` ALTER TABLE auth_sessions From d4d45baa7b3e0fadf262ece9577f8db85afe442e Mon Sep 17 00:00:00 2001 From: aditya mer Date: Wed, 6 May 2026 18:59:30 +0530 Subject: [PATCH 51/59] feat(provider): enhance ClaudeProvider with usage limits parsing and improve server layer integration --- .../src/provider/Layers/ClaudeProvider.ts | 137 +----------------- apps/server/src/server.ts | 13 +- .../components/settings/SettingsPanels.tsx | 10 +- 3 files changed, 15 insertions(+), 145 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 963529347c2..1abc7b39168 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -6,7 +6,7 @@ import { type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; -import { Effect, Option, Path, Result } from "effect"; +import { Effect, Layer, Option, Path, Ref, Result, Cache, Duration, Stream, Equal } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, @@ -34,10 +34,11 @@ import { } from "../providerSnapshot.ts"; import { compareCliVersions } from "../cliVersion.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; -import { probeClaudeUsageLimits } from "../claudeUsageProbe.ts"; +import { probeClaudeUsageLimits, parseClaudeUsageLimitsOutput } from "../claudeUsageProbe.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import type { PtyAdapterShape } from "../../terminal/Services/PTY.ts"; import type { ProviderUsageStateShape } from "../Services/ProviderUsageState.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -736,135 +737,3 @@ export const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): Serve }; export { probeClaudeCapabilities }; - -export const ClaudeProviderLive = Layer.effect( - ClaudeProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const usageProbeStateRef = yield* Ref.make( - new Map(), - ); - const usageProbeTtlMs = 5 * 60 * 1000; - - const subscriptionProbeCache = yield* Cache.make({ - capacity: 1, - timeToLive: Duration.minutes(5), - lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), - }); - const refreshUsageProbe = (key: string, binaryPath: string, launchArgs: string) => - Effect.gen(function* () { - yield* Ref.update(usageProbeStateRef, (current) => { - const next = new Map(current); - const existing = next.get(key); - next.set(key, { - rawOutput: existing?.rawOutput ?? "", - fetchedAtMs: existing?.fetchedAtMs ?? 0, - inFlight: true, - }); - return next; - }); - - const checkedAt = new Date().toISOString(); - const rawOutput = yield* Effect.tryPromise(() => - probeClaudeUsageLimits({ - binaryPath, - launchArgs, - cwd: process.cwd(), - checkedAt, - }), - ).pipe( - Effect.map((result) => result.rawOutput), - Effect.orElseSucceed(() => ""), - ); - - yield* Ref.update(usageProbeStateRef, (current) => { - const next = new Map(current); - next.set(key, { - rawOutput, - fetchedAtMs: Date.now(), - inFlight: false, - }); - return next; - }); - }).pipe( - Effect.ensuring( - Ref.update(usageProbeStateRef, (current) => { - const next = new Map(current); - const existing = next.get(key); - if (existing) { - next.set(key, { ...existing, inFlight: false }); - } - return next; - }), - ), - ); - - const checkProvider = checkClaudeProviderStatus( - (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( - Effect.map((probe) => probe?.subscriptionType), - ), - (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( - Effect.map((probe) => probe?.slashCommands), - ), - (input) => - Effect.gen(function* () { - const key = input.binaryPath; - const currentEntry = (yield* Ref.get(usageProbeStateRef)).get(key); - const isFresh = - currentEntry !== undefined && Date.now() - currentEntry.fetchedAtMs < usageProbeTtlMs; - - if ((!currentEntry || !isFresh) && !currentEntry?.inFlight) { - yield* Ref.update(usageProbeStateRef, (current) => { - const next = new Map(current); - const existing = next.get(key); - next.set(key, { - rawOutput: existing?.rawOutput ?? "", - fetchedAtMs: existing?.fetchedAtMs ?? 0, - inFlight: true, - }); - return next; - }); - yield* Effect.sync(() => { - void Effect.runPromiseExit( - refreshUsageProbe(key, input.binaryPath, input.launchArgs), - ); - }); - } - - const latestEntry = (yield* Ref.get(usageProbeStateRef)).get(key); - - if (!latestEntry || (latestEntry.inFlight && latestEntry.rawOutput.trim().length === 0)) { - return makeUnavailableUsageLimits({ - source: "claudeStatusProbe", - checkedAt: input.checkedAt, - reason: "Usage limits are still loading for this Claude account.", - }); - } - - return parseClaudeUsageLimitsOutput({ - output: latestEntry.rawOutput, - checkedAt: input.checkedAt, - }); - }), - ).pipe( - Effect.provideService(ServerSettingsService, serverSettings), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - - return yield* makeManagedServerProvider({ - getSettings: serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.claudeAgent), - Effect.orDie, - ), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.claudeAgent), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - initialSnapshot: makePendingClaudeProvider, - checkProvider, - }); - }), -); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index acc1899e40d..a91d07b545b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -148,6 +148,10 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderUsageStateLive), ); +const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), +); + const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), @@ -170,10 +174,6 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( - Layer.provide(VcsProjectConfig.layer), -); - const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), @@ -213,11 +213,6 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), ); -const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), -); - const TerminalLayerLive = Layer.mergeAll( PtyAdapterLive, TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3d3a70408b3..10a50b91fd3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,7 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, @@ -10,11 +10,13 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, + type ServerProvider, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { Equal } from "effect"; +import { cn } from "../../lib/utils"; import { APP_VERSION } from "../../branding"; import { canCheckForUpdate, @@ -48,7 +50,11 @@ import { selectThreadShellsAcrossEnvironments, useStore, } from "../../store"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { + formatRelativeTime, + formatRelativeTimeLabel, + formatRelativeTimeUntilLabel, +} from "../../timestampFormat"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { DraftInput } from "../ui/draft-input"; From 2243d19600fb87bd335748aa37f7dbe10298c554 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Wed, 6 May 2026 20:06:28 +0530 Subject: [PATCH 52/59] feat(provider): implement provider instance ID handling in usage state management --- .../029_AuthCompatibilityColumns.ts | 27 -------- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/provider/Drivers/CursorDriver.ts | 13 +++- .../src/provider/Layers/ClaudeAdapter.ts | 6 +- .../src/provider/Layers/ClaudeProvider.ts | 6 +- .../src/provider/Layers/CodexAdapter.ts | 1 + .../src/provider/Layers/CursorAdapter.ts | 6 +- .../src/provider/Layers/CursorProvider.ts | 34 ++++++--- .../Layers/ProviderUsageState.test.ts | 17 +++-- .../src/provider/Layers/ProviderUsageState.ts | 69 ++++++++++++------- .../provider/Services/ProviderUsageState.ts | 14 +++- 11 files changed, 119 insertions(+), 75 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts diff --git a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts b/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts deleted file mode 100644 index 2db9bf99837..00000000000 --- a/apps/server/src/persistence/Migrations/029_AuthCompatibilityColumns.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const existingTables = yield* sql<{ readonly name: string }>` - SELECT name - FROM sqlite_master - WHERE type = 'table' - AND name = 'auth_sessions' - `; - const tableNames = new Set(existingTables.map((table) => table.name)); - - if (tableNames.has("auth_sessions")) { - const sessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_sessions) - `; - - if (!sessionColumns.some((column) => column.name === "last_connected_at")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN last_connected_at TEXT - `; - } - } -}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index fc3e2b40574..15842fe5153 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -155,6 +155,7 @@ export const ClaudeDriver: ProviderDriver = { () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, ptyAdapter ?? undefined, + instanceId, providerUsageState, ).pipe( Effect.map(stampIdentity), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 72a6bae1f79..dde443bf9e7 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -13,7 +13,7 @@ * @module provider/Drivers/CursorDriver */ import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { Duration, Effect, FileSystem, Option, Path, Schema, Stream } from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -27,6 +27,7 @@ import { enrichCursorSnapshot, } from "../Layers/CursorProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { ProviderUsageState } from "../Services/ProviderUsageState.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -115,8 +116,16 @@ export const CursorDriver: ProviderDriver = { instanceId, }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); + const providerUsageState = Option.getOrUndefined( + yield* Effect.serviceOption(ProviderUsageState), + ); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + processEnv, + instanceId, + providerUsageState, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 556504d6cf4..9eb74c88350 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1007,7 +1007,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const makeEventStamp = () => + Effect.map(Effect.all({ eventId: nextEventId, createdAt: nowIso }), (stamp) => ({ + ...stamp, + providerInstanceId: boundInstanceId, + })); const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 1abc7b39168..e6fffeffdd2 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type ModelSelection, ProviderDriverKind, + type ProviderInstanceId, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -522,6 +523,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) => Effect.Effect, environment: NodeJS.ProcessEnv = process.env, ptyAdapter?: PtyAdapterShape, + instanceId?: ProviderInstanceId, providerUsageState?: ProviderUsageStateShape, ): Effect.fn.Return< ServerProviderDraft, @@ -649,7 +651,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const runtimeUsageLimits = providerUsageState - ? yield* providerUsageState.get(PROVIDER).pipe(Effect.orElseSucceed(() => undefined)) + ? yield* providerUsageState + .get(PROVIDER, instanceId) + .pipe(Effect.orElseSucceed(() => undefined)) : undefined; const usageLimits = runtimeUsageLimits diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5186dc29627..c21aec31e7f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -422,6 +422,7 @@ function runtimeEventBase( provider: event.provider, threadId: canonicalThreadId, createdAt: event.createdAt, + ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.itemId ? { itemId: asRuntimeItemId(event.itemId) } : {}), ...(event.requestId ? { requestId: asRuntimeRequestId(event.requestId) } : {}), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 335a3c880db..b5e39fb4b7e 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -325,7 +325,11 @@ export function makeCursorAdapter( const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const makeEventStamp = () => + Effect.map(Effect.all({ eventId: nextEventId, createdAt: nowIso }), (stamp) => ({ + ...stamp, + providerInstanceId: boundInstanceId, + })); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 27f32b6c1be..e0b009c404c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -4,10 +4,12 @@ import type { CursorSettings, ModelCapabilities, ProviderOptionSelection, + ProviderInstanceId, ServerProvider, ServerProviderAuth, ServerProviderModel, ServerProviderState, + ServerProviderUsageLimits, } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -35,6 +37,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; +import { type ProviderUsageStateShape } from "../Services/ProviderUsageState.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); @@ -741,16 +744,9 @@ export function buildCursorProviderSnapshot(input: { readonly parsed: CursorAboutResult; readonly discoveredModels?: ReadonlyArray; readonly discoveryWarning?: string; + readonly usageLimits?: ServerProviderUsageLimits; }): ServerProviderDraft { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); - const usageLimits = - input.parsed.auth.status === "authenticated" - ? makeUnavailableUsageLimits({ - source: "cursorAcp", - checkedAt: input.checkedAt, - reason: "Cursor Agent CLI does not expose usage information", - }) - : undefined; return buildServerProvider({ presentation: CURSOR_PRESENTATION, enabled: input.cursorSettings.enabled, @@ -768,7 +764,7 @@ export function buildCursorProviderSnapshot(input: { input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, auth: input.parsed.auth, ...(message ? { message } : {}), - ...(usageLimits ? { usageLimits } : {}), + ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), }, }); } @@ -1106,6 +1102,8 @@ const runCursorAboutCommand = ( export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment: NodeJS.ProcessEnv = process.env, + instanceId?: ProviderInstanceId, + providerUsageState?: ProviderUsageStateShape, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1217,6 +1215,23 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( discoveredModels = discoveryExit.value; } } + + const runtimeUsageLimits = providerUsageState + ? yield* providerUsageState + .get(PROVIDER, instanceId) + .pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + + const usageLimits = + runtimeUsageLimits ?? + (parsed.auth.status !== "unauthenticated" + ? makeUnavailableUsageLimits({ + source: "cursorAcp", + checkedAt, + reason: "Cursor does not expose subscription usage", + }) + : undefined); + return buildCursorProviderSnapshot({ checkedAt, cursorSettings, @@ -1226,6 +1241,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( () => [] as const, ), ...(discoveryWarning ? { discoveryWarning } : {}), + ...(usageLimits ? { usageLimits } : {}), }); }); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.test.ts b/apps/server/src/provider/Layers/ProviderUsageState.test.ts index 120211ef9a6..a9d4615b43d 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.test.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.test.ts @@ -34,12 +34,17 @@ describe("ProviderUsageStateLive", () => { Effect.gen(function* () { const usageState = yield* ProviderUsageState; - yield* usageState.set(ProviderDriverKind.make("cursor"), "thread-probe" as ThreadId, { - source: "cursorAcp", - available: true, - checkedAt: "2026-04-18T00:00:00.000Z", - windows: [{ kind: "session", label: "Context window", usedPercent: 25 }], - }); + yield* usageState.set( + ProviderDriverKind.make("cursor"), + undefined, + "thread-probe" as ThreadId, + { + source: "cursorAcp", + available: true, + checkedAt: "2026-04-18T00:00:00.000Z", + windows: [{ kind: "session", label: "Context window", usedPercent: 25 }], + }, + ); const first = yield* usageState.get(ProviderDriverKind.make("cursor")); yield* usageState.clear(ProviderDriverKind.make("cursor")); const second = yield* usageState.get(ProviderDriverKind.make("cursor")); diff --git a/apps/server/src/provider/Layers/ProviderUsageState.ts b/apps/server/src/provider/Layers/ProviderUsageState.ts index 2b7553cb1c0..3e197ce002c 100644 --- a/apps/server/src/provider/Layers/ProviderUsageState.ts +++ b/apps/server/src/provider/Layers/ProviderUsageState.ts @@ -1,5 +1,6 @@ import type { ProviderDriverKind, + ProviderInstanceId, ProviderRuntimeEvent, ServerProviderUsageLimits, ThreadId, @@ -34,53 +35,71 @@ function toCursorUsageLimits( }); } +function makeProviderInstanceKey( + provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, +): string { + if (providerInstanceId === undefined || providerInstanceId === null) { + return provider; + } + return `${provider}_${providerInstanceId}`; +} + export const ProviderUsageStateLive = Layer.effect( ProviderUsageState, Effect.gen(function* () { const providerService = yield* ProviderService; const stateRef = yield* Ref.make( new Map< - ProviderDriverKind, + string, Map >(), ); - const clearThreadUsage = (provider: ProviderDriverKind, threadId: ThreadId) => + const clearThreadUsage = ( + provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, + threadId: ThreadId, + ) => Ref.update(stateRef, (state) => { const next = new Map(state); - const existingThreadMap = next.get(provider); + const key = makeProviderInstanceKey(provider, providerInstanceId); + const existingThreadMap = next.get(key); if (!existingThreadMap) { return state; } const threadMap = new Map(existingThreadMap); threadMap.delete(threadId); if (threadMap.size === 0) { - next.delete(provider); + next.delete(key); } else { - next.set(provider, threadMap); + next.set(key, threadMap); } return next; }); const setThreadUsage = ( provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, threadId: ThreadId, usage: ServerProviderUsageLimits, updatedAtMs: number, ) => Ref.update(stateRef, (state) => { const next = new Map(state); - const threadMap = new Map(next.get(provider) ?? []); - next.set(provider, threadMap); + const key = makeProviderInstanceKey(provider, providerInstanceId); + const threadMap = new Map(next.get(key) ?? []); + next.set(key, threadMap); threadMap.set(threadId, { usage, updatedAtMs }); return next; }); const service: ProviderUsageStateShape = { - get: (provider) => + get: (provider, providerInstanceId) => Ref.get(stateRef).pipe( Effect.map((state) => { - const threadMap = state.get(provider); + const key = makeProviderInstanceKey(provider, providerInstanceId); + const threadMap = state.get(key); if (!threadMap || threadMap.size === 0) { return undefined; } @@ -95,47 +114,43 @@ export const ProviderUsageStateLive = Layer.effect( return latest?.usage; }), ), - set: (provider, threadId, usage) => + set: (provider, providerInstanceId, threadId, usage) => Ref.update(stateRef, (state) => { const next = new Map(state); + const key = makeProviderInstanceKey(provider, providerInstanceId); if (usage === undefined) { - const existingThreadMap = next.get(provider); + const existingThreadMap = next.get(key); if (existingThreadMap) { const newThreadMap = new Map(existingThreadMap); newThreadMap.delete(threadId); if (newThreadMap.size === 0) { - next.delete(provider); + next.delete(key); } else { - next.set(provider, newThreadMap); + next.set(key, newThreadMap); } } } else { - let threadMap = next.get(provider); - if (!threadMap) { - threadMap = new Map(); - } else { - threadMap = new Map(threadMap); - } - next.set(provider, threadMap); + const threadMap = new Map(next.get(key) ?? []); + next.set(key, threadMap); threadMap.set(threadId, { usage, updatedAtMs: Date.now() }); } return next; }), - clear: (provider) => + clear: (provider, providerInstanceId) => Ref.update(stateRef, (state) => { - if (!state.has(provider)) { - return state; - } const next = new Map(state); - next.delete(provider); + const key = makeProviderInstanceKey(provider, providerInstanceId); + next.delete(key); return next; }), }; yield* Stream.runForEach(providerService.streamEvents, (event) => Effect.gen(function* () { + const providerInstanceId = event.providerInstanceId; + if (event.type === "session.started" || event.type === "session.exited") { - yield* clearThreadUsage(event.provider, event.threadId); + yield* clearThreadUsage(event.provider, providerInstanceId, event.threadId); return; } @@ -147,6 +162,7 @@ export const ProviderUsageStateLive = Layer.effect( yield* setThreadUsage( CURSOR_DRIVER, + providerInstanceId, event.threadId, usage, Date.parse(event.createdAt) || Date.now(), @@ -173,6 +189,7 @@ export const ProviderUsageStateLive = Layer.effect( yield* setThreadUsage( CLAUDE_DRIVER, + providerInstanceId, event.threadId, usage, Date.parse(event.createdAt) || Date.now(), diff --git a/apps/server/src/provider/Services/ProviderUsageState.ts b/apps/server/src/provider/Services/ProviderUsageState.ts index b0734bd52d1..8ed00ad7a89 100644 --- a/apps/server/src/provider/Services/ProviderUsageState.ts +++ b/apps/server/src/provider/Services/ProviderUsageState.ts @@ -1,17 +1,27 @@ -import type { ProviderDriverKind, ServerProviderUsageLimits, ThreadId } from "@t3tools/contracts"; +import type { + ProviderDriverKind, + ProviderInstanceId, + ServerProviderUsageLimits, + ThreadId, +} from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect } from "effect"; export interface ProviderUsageStateShape { readonly get: ( provider: ProviderDriverKind, + providerInstanceId?: ProviderInstanceId, ) => Effect.Effect; readonly set: ( provider: ProviderDriverKind, + providerInstanceId: ProviderInstanceId | undefined, threadId: ThreadId, usage: ServerProviderUsageLimits | undefined, ) => Effect.Effect; - readonly clear: (provider: ProviderDriverKind) => Effect.Effect; + readonly clear: ( + provider: ProviderDriverKind, + providerInstanceId?: ProviderInstanceId, + ) => Effect.Effect; } export class ProviderUsageState extends Context.Service< From d1c0982d293f3724785b110526604edaf28e8aff Mon Sep 17 00:00:00 2001 From: aditya mer Date: Wed, 6 May 2026 21:09:38 +0530 Subject: [PATCH 53/59] feat(provider): enhance usage limits probing with clock abstraction and argument parsing --- .../src/provider/claudeUsageProbe.test.ts | 207 +++++++++++++++--- apps/server/src/provider/claudeUsageProbe.ts | 109 +++++++-- 2 files changed, 258 insertions(+), 58 deletions(-) diff --git a/apps/server/src/provider/claudeUsageProbe.test.ts b/apps/server/src/provider/claudeUsageProbe.test.ts index 38585d2a7df..0b08ab88048 100644 --- a/apps/server/src/provider/claudeUsageProbe.test.ts +++ b/apps/server/src/provider/claudeUsageProbe.test.ts @@ -1,8 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import * as Effect from "effect/Effect"; +import { PtySpawnError } from "../terminal/Services/PTY.ts"; +import type { PtySpawnInput } from "../terminal/Services/PTY.ts"; import type { PtyAdapterShape, PtyProcess } from "../terminal/Services/PTY.ts"; +import { + parseClaudeRuntimeUsageLimits, + parseClaudeUsageLimitsOutput, + probeClaudeUsageLimits, + shouldRequestClaudeUsageFallback, + type ProbeClock, +} from "./claudeUsageProbe.ts"; + class MockPtyChild implements PtyProcess { public readonly writes: string[] = []; public readonly kill = vi.fn(); @@ -55,30 +65,71 @@ class MockPtyChild implements PtyProcess { function makeMockPtyAdapter(child: MockPtyChild): PtyAdapterShape { return { - spawn: () => { - child.writes.length = 0; - child.kill.mockClear(); - return Effect.succeed(child); - }, + spawn: () => Effect.succeed(child), }; } -import { - parseClaudeRuntimeUsageLimits, - parseClaudeUsageLimitsOutput, - probeClaudeUsageLimits, - shouldRequestClaudeUsageFallback, -} from "./claudeUsageProbe.ts"; +function makeCapturingPtyAdapter(input: { + readonly child: MockPtyChild; + readonly onSpawn: (spawnInput: PtySpawnInput) => void; +}): PtyAdapterShape { + return { + spawn: (spawnInput) => { + input.onSpawn(spawnInput); + return Effect.succeed(input.child); + }, + }; +} -describe("claudeUsageProbe", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); +function createFakeClock(): ProbeClock & { advance(ms: number): void } { + const timers: Array<{ + id: number; + ms: number; + fn: () => void; + fired: boolean; + cancelled: boolean; + }> = []; + let nextId = 1; + + const fakeSetTimeout = ((fn: () => void, ms?: number) => { + const id = nextId++; + timers.push({ + id, + ms: ms ?? 0, + fn, + fired: false, + cancelled: false, + }); + return id as unknown as ReturnType; + }) as typeof setTimeout; + + const fakeClearTimeout = ((id: ReturnType) => { + const numericId = typeof id === "number" ? id : (id as unknown as number); + const entry = timers.find((t) => t.id === numericId); + if (entry) { + entry.cancelled = true; + } + }) as typeof clearTimeout; + + const advance = (ms: number) => { + for (const timer of timers) { + if (timer.fired || timer.cancelled) continue; + timer.ms -= ms; + if (timer.ms <= 0) { + timer.fired = true; + timer.fn(); + } + } + }; - afterEach(() => { - vi.useRealTimers(); - }); + return { + setTimeout: fakeSetTimeout, + clearTimeout: fakeClearTimeout, + advance, + }; +} +describe("claudeUsageProbe", () => { it("parses session and weekly windows from status output", () => { expect( parseClaudeUsageLimitsOutput({ @@ -239,9 +290,11 @@ describe("claudeUsageProbe", () => { ).toBe(false); }); - it("triggers /usage fallback when /status remains quiet", async () => { + it("resolves immediately when /status returns usable quota output", async () => { const child = new MockPtyChild(); const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + const probePromise = Effect.runPromise( probeClaudeUsageLimits( { @@ -250,22 +303,25 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }, ptyAdapter, + clock, ), ); expect(child.writes).toEqual(["/status\r"]); - await vi.advanceTimersByTimeAsync(150); - expect(child.writes).toEqual(["/status\r", "/usage\r"]); + child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); - child.emitExit(); const result = await probePromise; - expect(result.usageLimits.available).toBe(false); + expect(result.usageLimits.available).toBe(true); + expect(child.writes).toEqual(["/status\r"]); + expect(child.kill).toHaveBeenCalled(); }); - it("triggers /usage fallback for short non-empty status output", async () => { + it("sends /usage fallback when /status output is not enough", async () => { const child = new MockPtyChild(); const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + const probePromise = Effect.runPromise( probeClaudeUsageLimits( { @@ -274,22 +330,27 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }, ptyAdapter, + clock, ), ); - child.emitData("Authenticated as Claude Max\n"); + expect(child.writes).toEqual(["/status\r"]); - await vi.advanceTimersByTimeAsync(150); + child.emitData("Authenticated as Claude Max\n"); + clock.advance(200); expect(child.writes).toEqual(["/status\r", "/usage\r"]); - child.emitExit(); + child.emitData("Session usage 55% resets at 2026-04-17T15:00:00Z\n"); const result = await probePromise; - expect(result.usageLimits.available).toBe(false); + expect(result.usageLimits.available).toBe(true); + expect(child.kill).toHaveBeenCalled(); }); - it("skips /usage fallback when /status already returns usable quota output", async () => { + it("resolves unavailable when process exits with no usable data", async () => { const child = new MockPtyChild(); const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + const probePromise = Effect.runPromise( probeClaudeUsageLimits( { @@ -298,19 +359,27 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }, ptyAdapter, + clock, ), ); - child.emitData("Session usage 42% resets at 2026-04-17T14:00:00Z\n"); + expect(child.writes).toEqual(["/status\r"]); + + child.emitData("Authenticated as Claude Max\n"); + clock.advance(200); + expect(child.writes).toEqual(["/status\r", "/usage\r"]); + child.emitExit(); const result = await probePromise; - expect(result.usageLimits.available).toBe(true); - expect(child.writes).toEqual(["/status\r"]); + expect(result.usageLimits.available).toBe(false); + expect(child.kill).toHaveBeenCalled(); }); - it("times out cleanly when neither /status nor /usage yields usable quota data", async () => { + it("resolves unavailable on timeout with no usable data", async () => { const child = new MockPtyChild(); const ptyAdapter = makeMockPtyAdapter(child); + const clock = createFakeClock(); + const probePromise = Effect.runPromise( probeClaudeUsageLimits( { @@ -319,17 +388,83 @@ describe("claudeUsageProbe", () => { checkedAt: "2026-04-17T10:00:00.000Z", }, ptyAdapter, + clock, ), ); - await vi.advanceTimersByTimeAsync(150); + clock.advance(200); expect(child.writes).toEqual(["/status\r", "/usage\r"]); - await vi.advanceTimersByTimeAsync(4_000); + clock.advance(4_000); const result = await probePromise; expect(result.usageLimits.available).toBe(false); expect(result.rawOutput).toBe(""); expect(child.writes.filter((entry) => entry === "/usage\r")).toHaveLength(1); + expect(child.kill).toHaveBeenCalled(); + }); + + it("returns unavailable result when spawn fails", async () => { + const failingAdapter: PtyAdapterShape = { + spawn: () => + Effect.fail( + new PtySpawnError({ + adapter: "mock", + message: "spawn failed", + }), + ), + }; + + const result = await Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + failingAdapter, + ), + ); + + expect(result.usageLimits.available).toBe(false); + expect(result.usageLimits.reason).toBe("Failed to spawn Claude process for usage probe."); + expect(result.rawOutput).toBe(""); + }); + + it("preserves quoted launch arguments when spawning the probe process", async () => { + const child = new MockPtyChild(); + let capturedSpawnInput: PtySpawnInput | undefined; + const ptyAdapter = makeCapturingPtyAdapter({ + child, + onSpawn: (spawnInput) => { + capturedSpawnInput = spawnInput; + }, + }); + + const probePromise = Effect.runPromise( + probeClaudeUsageLimits( + { + binaryPath: "claude", + launchArgs: '--model "claude sonnet" --cwd "/tmp/with spaces" --note "say \\"hi\\""', + cwd: "/tmp", + checkedAt: "2026-04-17T10:00:00.000Z", + }, + ptyAdapter, + ), + ); + + child.emitExit(); + await probePromise; + + expect(capturedSpawnInput?.args).toEqual([ + "--model", + "claude sonnet", + "--cwd", + "/tmp/with spaces", + "--note", + 'say "hi"', + "--permission-mode", + "plan", + ]); }); }); diff --git a/apps/server/src/provider/claudeUsageProbe.ts b/apps/server/src/provider/claudeUsageProbe.ts index 1b5a1aa3e08..d11b4939ef6 100644 --- a/apps/server/src/provider/claudeUsageProbe.ts +++ b/apps/server/src/provider/claudeUsageProbe.ts @@ -255,9 +255,76 @@ export function parseClaudeUsageLimitsOutput(input: { }); } +export interface ProbeClock { + readonly setTimeout: typeof setTimeout; + readonly clearTimeout: typeof clearTimeout; +} + +const defaultClock: ProbeClock = { setTimeout, clearTimeout }; + +function splitLaunchArgs(launchArgs?: string): string[] { + if (!launchArgs?.trim()) { + return []; + } + + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaping = false; + + const pushCurrent = () => { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + }; + + for (const character of launchArgs) { + if (escaping) { + current += character; + escaping = false; + continue; + } + + if (character === "\\") { + escaping = true; + continue; + } + + if (quote) { + if (character === quote) { + quote = null; + } else { + current += character; + } + continue; + } + + if (character === "'" || character === '"') { + quote = character; + continue; + } + + if (/\s/.test(character)) { + pushCurrent(); + continue; + } + + current += character; + } + + if (escaping) { + current += "\\"; + } + + pushCurrent(); + return tokens; +} + function runProbeLoop( child: PtyProcess, input: ClaudeUsageProbeInput, + clock: ProbeClock, ): Promise { return new Promise((resolve) => { let rawOutput = ""; @@ -265,7 +332,7 @@ function runProbeLoop( let fallbackTimer: ReturnType | undefined; let sentFallback = false; - const timeout = setTimeout(() => { + const timeout = clock.setTimeout(() => { finish(); }, CLAUDE_USAGE_PROBE_TIMEOUT_MS); @@ -274,9 +341,9 @@ function runProbeLoop( return; } if (fallbackTimer) { - clearTimeout(fallbackTimer); + clock.clearTimeout(fallbackTimer); } - fallbackTimer = setTimeout(() => { + fallbackTimer = clock.setTimeout(() => { fallbackTimer = undefined; maybeRequestFallback(); }, CLAUDE_USAGE_FALLBACK_IDLE_MS); @@ -285,9 +352,9 @@ function runProbeLoop( const finish = () => { if (settled) return; settled = true; - clearTimeout(timeout); + clock.clearTimeout(timeout); if (fallbackTimer) { - clearTimeout(fallbackTimer); + clock.clearTimeout(fallbackTimer); } offData(); offExit(); @@ -348,24 +415,22 @@ function runProbeLoop( export function probeClaudeUsageLimits( input: ClaudeUsageProbeInput, ptyAdapter: PtyAdapterShape, + clock: ProbeClock = defaultClock, ): Effect.Effect { - const probeArgs = [ - ...(input.launchArgs?.trim().split(/\s+/).filter(Boolean) ?? []), - "--permission-mode", - "plan", - ]; - - return Effect.promise(async () => { - const spawnResult = ptyAdapter.spawn({ - shell: input.binaryPath, - args: probeArgs, - cwd: input.cwd, - cols: 120, - rows: 40, - env: input.environment ?? process.env, - }); + const probeArgs = [...splitLaunchArgs(input.launchArgs), "--permission-mode", "plan"]; + + return Effect.gen(function* () { + const child = yield* ptyAdapter + .spawn({ + shell: input.binaryPath, + args: probeArgs, + cwd: input.cwd, + cols: 120, + rows: 40, + env: input.environment ?? process.env, + }) + .pipe(Effect.orElseSucceed(() => null as PtyProcess | null)); - const child = await Effect.runPromise(spawnResult).catch(() => null); if (!child) { return { usageLimits: makeUnavailableUsageLimits({ @@ -377,6 +442,6 @@ export function probeClaudeUsageLimits( }; } - return runProbeLoop(child, input); + return yield* Effect.promise(() => runProbeLoop(child, input, clock)); }); } From 7528f752ea3b0cb729d9ef54954c7f7f0936d7aa Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 7 May 2026 08:02:45 +0530 Subject: [PATCH 54/59] feat: adjust usage threshold to 70% and remove ProviderUsageStateLive from server layer --- apps/server/src/server.ts | 1 - apps/web/src/components/settings/ProviderInstanceCard.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a91d07b545b..07cf1f568de 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -145,7 +145,6 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(RuntimeReceiptBusLive), - Layer.provideMerge(ProviderUsageStateLive), ); const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 5b4b592591d..0377f833d6a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -48,7 +48,7 @@ import { function usageBarColor(percent: number): string { if (percent >= 90) return "bg-destructive"; - if (percent >= 75) return "bg-warning"; + if (percent >= 70) return "bg-warning"; return "bg-foreground"; } From c5ba79e8a56b9408dd3f816d1c5393afa0188647 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 7 May 2026 08:33:39 +0530 Subject: [PATCH 55/59] refactor(settings): remove unused usage limit functions and clean up imports --- .../components/settings/SettingsPanels.tsx | 127 +----------------- 1 file changed, 2 insertions(+), 125 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 10a50b91fd3..ee75fba5d06 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,7 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, @@ -10,13 +10,11 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, - type ServerProvider, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { Equal } from "effect"; -import { cn } from "../../lib/utils"; import { APP_VERSION } from "../../branding"; import { canCheckForUpdate, @@ -50,11 +48,7 @@ import { selectThreadShellsAcrossEnvironments, useStore, } from "../../store"; -import { - formatRelativeTime, - formatRelativeTimeLabel, - formatRelativeTimeUntilLabel, -} from "../../timestampFormat"; +import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { DraftInput } from "../ui/draft-input"; @@ -151,123 +145,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } ); } -function getUsageMeterToneClass(usedPercent: number): string { - if (usedPercent >= 90) return "bg-destructive"; - if (usedPercent >= 70) return "bg-warning"; - return "bg-foreground/88"; -} - -function getUsageRemainingLabel(usedPercent: number): string { - const remainingPercent = Math.max(0, Math.min(100, 100 - Math.round(usedPercent))); - return `${remainingPercent}% remaining`; -} - -function getUsageTrackClass(usedPercent: number): string { - if (usedPercent >= 90) return "bg-destructive/12"; - if (usedPercent >= 70) return "bg-warning/12"; - return "bg-black/5 dark:bg-white/6"; -} - -function getUsageResetLabel(resetsAt: string | undefined): string | null { - if (!resetsAt) return null; - const diffMs = new Date(resetsAt).getTime() - Date.now(); - if (!Number.isFinite(diffMs) || diffMs <= 0) { - return null; - } - const relativeLabel = formatRelativeTimeUntilLabel(resetsAt); - if (relativeLabel === "Soon") { - return "Resets soon"; - } - if (diffMs < 6 * 60 * 60 * 1000) { - return `Resets in ${relativeLabel.replace(/ left$/, "")}`; - } - const now = new Date(); - const resetDate = new Date(resetsAt); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - const isResetTomorrow = - resetDate.toDateString() !== now.toDateString() && - resetDate.toDateString() === tomorrow.toDateString(); - if (isResetTomorrow) { - return "Resets tomorrow"; - } - return `Resets in ${relativeLabel.replace(/ left$/, "")}`; -} - -function ProviderUsageLimitsBlock({ provider }: { provider: ServerProvider | undefined }) { - useRelativeTimeTick(); - - if (!provider || !provider.enabled || !provider.installed || !provider.usageLimits) { - return null; - } - - if (!provider.usageLimits.available) { - return ( -

- {provider.usageLimits?.reason ?? "Unable to fetch usage"} -

- ); - } - - return ( -
- {provider.usageLimits.windows.map((window) => { - const percentageLabel = `${Math.round(window.usedPercent)}%`; - const resetLabel = getUsageResetLabel(window.resetsAt); - const normalizedWidth = Math.max(0, Math.min(100, window.usedPercent)); - const remainingLabel = getUsageRemainingLabel(window.usedPercent); - - return ( -
-
- - {window.label} limit - - {remainingLabel} -
-
-
-
- {resetLabel ? ( -

{resetLabel}

- ) : null} -
- ); - })} -
- ); -} - -function ProviderCardShell({ children, expanded }: { children: ReactNode; expanded: boolean }) { - return ( -
-
- {children} -
- ); -} - function AboutVersionTitle() { return ( From 8ce8f6449b6fd85dfcaad1155ee694c5538966be Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 7 May 2026 11:35:56 +0530 Subject: [PATCH 56/59] test(provider): add test for maintaining intermediate windows as sessions in usage limits --- .../src/provider/providerUsageLimits.test.ts | 34 +++++++++++++++++++ .../src/provider/providerUsageLimits.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts index 74211c33b87..1fee21d0156 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -60,4 +60,38 @@ describe("providerUsageLimits", () => { }), ).toBe("weekly"); }); + + it("keeps intermediate windows as session instead of dropping them", () => { + expect( + makeUsageLimitsSnapshot({ + source: "codexAppServer", + checkedAt: "2026-04-17T10:00:00.000Z", + unavailableReason: "missing", + windows: [ + { label: "Short", usedPercent: 10, windowDurationMins: 60 }, + { label: "Middle", usedPercent: 20, windowDurationMins: 1440 }, + { label: "Long", usedPercent: 30, windowDurationMins: 4320 }, + ], + }).windows, + ).toEqual([ + { + kind: "session", + label: "Session", + usedPercent: 10, + windowDurationMins: 60, + }, + { + kind: "session", + label: "Session", + usedPercent: 20, + windowDurationMins: 1440, + }, + { + kind: "weekly", + label: "Weekly", + usedPercent: 30, + windowDurationMins: 4320, + }, + ]); + }); }); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index 7a23ac0e9a2..309d4263938 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -33,7 +33,7 @@ export function windowKindFromDuration(input: { if (duration === input.shortestWindowDurationMins) { return "session"; } - return undefined; + return "session"; } export function normalizeUsageWindows( From fee41100740c36d8c55475fcc100dddff655c40e Mon Sep 17 00:00:00 2001 From: aditya mer Date: Thu, 7 May 2026 12:24:23 +0530 Subject: [PATCH 57/59] chore(migrations): remove AuthCompatibilityColumns migration and update migration entries --- apps/server/src/persistence/Migrations.ts | 2 -- .../030_AuthCompatibilityColumns.ts | 27 ------------------- .../settings/ProviderInstanceCard.tsx | 3 ++- 3 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 apps/server/src/persistence/Migrations/030_AuthCompatibilityColumns.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index e4b5cd94bde..c0918de8493 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -42,7 +42,6 @@ import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; -import Migration0030 from "./Migrations/030_AuthCompatibilityColumns.ts"; /** * Migration loader with all migrations defined inline. @@ -84,7 +83,6 @@ export const migrationEntries = [ [27, "ProviderSessionRuntimeInstanceId", Migration0027], [28, "ProjectionThreadSessionInstanceId", Migration0028], [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], - [30, "AuthCompatibilityColumns", Migration0030], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/030_AuthCompatibilityColumns.ts b/apps/server/src/persistence/Migrations/030_AuthCompatibilityColumns.ts deleted file mode 100644 index 2db9bf99837..00000000000 --- a/apps/server/src/persistence/Migrations/030_AuthCompatibilityColumns.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const existingTables = yield* sql<{ readonly name: string }>` - SELECT name - FROM sqlite_master - WHERE type = 'table' - AND name = 'auth_sessions' - `; - const tableNames = new Set(existingTables.map((table) => table.name)); - - if (tableNames.has("auth_sessions")) { - const sessionColumns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(auth_sessions) - `; - - if (!sessionColumns.some((column) => column.name === "last_connected_at")) { - yield* sql` - ALTER TABLE auth_sessions - ADD COLUMN last_connected_at TEXT - `; - } - } -}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 0377f833d6a..e069b1109e1 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -76,6 +76,7 @@ function ProviderUsageBars(props: { const color = usageBarColor(window.usedPercent); const roundedPercent = Math.round(window.usedPercent); const remainingPercent = 100 - roundedPercent; + const windowKey = `${window.kind}:${window.windowDurationMins ?? "unknown"}:${window.resetsAt ?? "none"}`; const resetDateStr = window.resetsAt ? new Date(window.resetsAt).toLocaleString("en-GB", { @@ -88,7 +89,7 @@ function ProviderUsageBars(props: { : null; return ( -
+
{window.label} {remainingPercent}% remaining From 1d9fabf19c29633e56fc660701976fdae25cf642 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Fri, 8 May 2026 08:16:20 +0530 Subject: [PATCH 58/59] refactor(server): clean up imports in server.ts and ClaudeProvider.ts --- apps/server/src/provider/Layers/ClaudeProvider.ts | 5 ++--- apps/server/src/server.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e6fffeffdd2..114e3af5984 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -7,7 +7,7 @@ import { type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; -import { Effect, Layer, Option, Path, Ref, Result, Cache, Duration, Stream, Equal } from "effect"; +import { Effect, Option, Path, Result } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, @@ -35,11 +35,10 @@ import { } from "../providerSnapshot.ts"; import { compareCliVersions } from "../cliVersion.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; -import { probeClaudeUsageLimits, parseClaudeUsageLimitsOutput } from "../claudeUsageProbe.ts"; +import { probeClaudeUsageLimits } from "../claudeUsageProbe.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import type { PtyAdapterShape } from "../../terminal/Services/PTY.ts"; import type { ProviderUsageStateShape } from "../Services/ProviderUsageState.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 07cf1f568de..cc89b9c2d35 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,7 +20,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; -import { ProviderServiceLive, makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import { ProviderUsageStateLive } from "./provider/Layers/ProviderUsageState.ts"; import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; From 76c23dbf6f0a21aed87104bf79301a78ac3d6023 Mon Sep 17 00:00:00 2001 From: aditya mer Date: Sat, 9 May 2026 12:31:29 +0530 Subject: [PATCH 59/59] Add providerInstanceId to event payloads in makeClaudeAdapter --- .../src/provider/Layers/ClaudeAdapter.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7c71616273f..9fcd518ead5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1189,6 +1189,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: deltaStamp.eventId, provider: PROVIDER, createdAt: deltaStamp.createdAt, + providerInstanceId: deltaStamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, itemId: asRuntimeItemId(block.itemId), @@ -1220,6 +1221,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, itemId: asRuntimeItemId(block.itemId), threadId: context.session.threadId, turnId: turnState.turnId, @@ -1312,6 +1314,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, payload: { providerThreadId: nextThreadId, @@ -1343,6 +1346,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { @@ -1366,6 +1370,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), payload: { @@ -1407,6 +1412,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, payload: { @@ -1472,6 +1478,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: usageStamp.eventId, provider: PROVIDER, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, threadId: context.session.threadId, payload: { usage: usageSnapshot, @@ -1486,6 +1493,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, payload: { state: status, @@ -1509,6 +1517,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: toolStamp.eventId, provider: PROVIDER, createdAt: toolStamp.createdAt, + providerInstanceId: toolStamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, itemId: asRuntimeItemId(tool.itemId), @@ -1556,6 +1565,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: usageStamp.eventId, provider: PROVIDER, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, payload: { @@ -1571,6 +1581,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, turnId: turnState.turnId, payload: { @@ -1643,6 +1654,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, turnId: context.turnState.turnId, ...(assistantBlockEntry?.block @@ -1706,6 +1718,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -1743,6 +1756,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: planStamp.eventId, provider: PROVIDER, createdAt: planStamp.createdAt, + providerInstanceId: planStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -1805,6 +1819,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), @@ -1882,6 +1897,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: updatedStamp.eventId, provider: PROVIDER, createdAt: updatedStamp.createdAt, + providerInstanceId: updatedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), @@ -1910,6 +1926,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: deltaStamp.eventId, provider: PROVIDER, createdAt: deltaStamp.createdAt, + providerInstanceId: deltaStamp.providerInstanceId, threadId: context.session.threadId, turnId: context.turnState.turnId, itemId: asRuntimeItemId(tool.itemId), @@ -1934,6 +1951,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: completedStamp.eventId, provider: PROVIDER, createdAt: completedStamp.createdAt, + providerInstanceId: completedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), @@ -1992,6 +2010,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: turnStartedStamp.eventId, provider: PROVIDER, createdAt: turnStartedStamp.createdAt, + providerInstanceId: turnStartedStamp.providerInstanceId, threadId: context.session.threadId, turnId, payload: {}, @@ -2076,6 +2095,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), providerRefs: nativeProviderRefs(context), @@ -2179,6 +2199,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, eventId: usageStamp.eventId, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, type: "thread.token-usage.updated", payload: { usage: normalizedUsage, @@ -2211,6 +2232,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...base, eventId: usageStamp.eventId, createdAt: usageStamp.createdAt, + providerInstanceId: usageStamp.providerInstanceId, type: "thread.token-usage.updated", payload: { usage: normalizedUsage, @@ -2270,6 +2292,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), providerRefs: nativeProviderRefs(context), @@ -2432,6 +2455,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), @@ -2486,6 +2510,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: stamp.eventId, provider: PROVIDER, createdAt: stamp.createdAt, + providerInstanceId: stamp.providerInstanceId, threadId: context.session.threadId, payload: { reason: "Session stopped", @@ -2629,6 +2654,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: requestedStamp.eventId, provider: PROVIDER, createdAt: requestedStamp.createdAt, + providerInstanceId: requestedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -2676,6 +2702,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: resolvedStamp.eventId, provider: PROVIDER, createdAt: resolvedStamp.createdAt, + providerInstanceId: resolvedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { @@ -2779,6 +2806,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: requestedStamp.eventId, provider: PROVIDER, createdAt: requestedStamp.createdAt, + providerInstanceId: requestedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), @@ -2827,6 +2855,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: resolvedStamp.eventId, provider: PROVIDER, createdAt: resolvedStamp.createdAt, + providerInstanceId: resolvedStamp.providerInstanceId, threadId: context.session.threadId, ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), requestId: asRuntimeRequestId(requestId), @@ -3016,6 +3045,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: sessionStartedStamp.eventId, provider: PROVIDER, createdAt: sessionStartedStamp.createdAt, + providerInstanceId: sessionStartedStamp.providerInstanceId, threadId, payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, providerRefs: {}, @@ -3027,6 +3057,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: configuredStamp.eventId, provider: PROVIDER, createdAt: configuredStamp.createdAt, + providerInstanceId: configuredStamp.providerInstanceId, threadId, payload: { config: { @@ -3046,6 +3077,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: readyStamp.eventId, provider: PROVIDER, createdAt: readyStamp.createdAt, + providerInstanceId: readyStamp.providerInstanceId, threadId, payload: { state: "ready", @@ -3150,6 +3182,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( eventId: turnStartedStamp.eventId, provider: PROVIDER, createdAt: turnStartedStamp.createdAt, + providerInstanceId: turnStartedStamp.providerInstanceId, threadId: context.session.threadId, turnId, payload: modelSelection?.model ? { model: modelSelection.model } : {},