Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ GitHub-hosted runners instead of upstream's Blacksmith ones, nothing needing cre
fork lacks, and unsigned desktop artifacts published as pruned development-build prereleases),
plus fork identity (this file, the `README.md` banner, the `AGENTS.md` policy sections, no
update checking, a sidebar link to this repo) and a handful of source changes: multi-instance
provider support (15, 16, 19), a configurable worktree branch prefix (17), and five web UX
changes (5, 6, 7, 18, 21). Everything else — `native/`, `scripts/`, `infra/`, `pnpm-lock.yaml`,
provider support (15, 16, 19), a configurable worktree branch prefix (17), provider
subscription usage in the picker and context bubble (22), and five web UX changes
(5, 6, 7, 18, 21). Everything else — `native/`, `scripts/`, `infra/`, `pnpm-lock.yaml`,
`pnpm-workspace.yaml`, `apps/server/src/persistence/` — is byte-identical to upstream.

This file is the authoritative list of what sets this fork apart, and it is written to be used
Expand Down Expand Up @@ -518,6 +519,85 @@ untouched lines; that is not an intentional edit.
button next to the scope menu and no menu item.
- **Browser-only:** the menu item placement and the widened trigger row.

### 22. Subscription usage for Claude and Codex, in the picker and the context bubble

- **Intent.** A user driving two or three subscriptions all day has no way to see which one has
room left without leaving the app, so the choice of provider is made blind and the first
signal that a window is exhausted is a refused turn mid-task. Both priority providers already
report this and T3 Code already received it and dropped it on the floor: `ClaudeAdapter` and
`CodexAdapter` translate the CLIs' native rate-limit messages into
`account.rate-limits.updated`, `providerRuntime.ts` declares the event, and
`ProviderRuntimeIngestion` has no case for it. The allowance is now read where the provider
is chosen (the model picker) and where the current turn's cost is already shown (the context
bubble under the composer).
- **Files:** `packages/contracts/src/server.ts`,
`apps/server/src/provider/providerSubscriptionUsage.ts` (new),
`apps/server/src/provider/{providerSnapshot,providerStatusCache}.ts`,
`apps/server/src/provider/Layers/{ClaudeProvider,CodexProvider}.ts`,
`apps/web/src/components/chat/{SubscriptionUsage.logic.ts,SubscriptionUsageMeters.tsx,ContextWindowMeter.tsx,ModelPickerContent.tsx,ChatComposer.tsx}`,
plus `providerSubscriptionUsage.test.ts`, `SubscriptionUsage.logic.test.ts` and the
`get_usage` round-trip added to `ClaudeCapabilitiesProbe.test.ts`
- **Re-apply notes.** `ServerProvider` gains `subscriptionUsage` as an `optionalKey`, following
`versionAdvisory`/`updateState`; it rides the existing `providerStatuses` push, so no new
channel and no contract version bump. Decisions worth keeping:
1. **Read the snapshot, do not accumulate the stream.** Claude's `rate_limit_event` carries
one window per event (`rateLimitType` is a single value), so reconstructing the full set
from the stream means holding state and still showing nothing until a turn has run. The
SDK's structured `/usage` control request returns every window at once, including the
per-model weekly buckets. Codex has the same shape available as `account/rateLimits/read`.
2. **Collected in the status probe, not during a turn.** Both probes already spawn the CLI and
already ask it an account question (`account/read`; the SDK initialization handshake), so
the allowance is known before the first turn and the picker is useful cold. No new poll
loop was added — a probe spawns a process, and the existing refresh cadence is the budget.
3. **`Effect.timeoutOption` does not bound the Claude request.** It cannot interrupt a
`tryPromise` whose promise never settles, and a CLI that does not implement the control
request simply never answers it — the probe hangs past its own 25s ceiling. The deadline
therefore lives _inside_ the promise, as an `AbortSignal.timeout` raced against the call
and folded together with the fiber's signal. Codex needs none of this: its client resolves
through `Deferred.await`, which is interruptible, so the ordinary Effect timeout works.
`ClaudeCapabilitiesProbe.test.ts` is the regression guard — its fake CLI answered only
`initialize`, and the un-deadlined version hung it for the full 120s test timeout.
4. **The request is gated on `subscriptionType`.** Only a claude.ai plan has windows to
report, so API-key, Bedrock, Vertex and logged-out instances skip the call entirely rather
than paying the deadline every refresh to learn what the account payload already said.
5. **The SDK is read structurally, not by its types.** The method is named
`usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET` and documented to be renamed;
`model_scoped` (the per-model buckets, e.g. Fable) ships server-side ahead of the npm
types and is absent from the pinned `0.3.170` typings. The call site takes `unknown` and
the normalizer validates, so both a rename and a field arriving early are non-events —
and **no lockfile bump was needed**, since the per-model buckets come over the wire from
the user's own installed CLI, not from the npm package.
6. **Never cached to disk.** `writeProviderStatusCache` already strips `updateState`;
`subscriptionUsage` is stripped the same way. A percentage rehydrated from a previous run
is worse than no percentage. The client independently ages a snapshot out after an hour,
so a tab left open overnight shows nothing rather than yesterday's allowance.
7. **Stored as used, rendered as left.** Both providers report consumption
(Codex `usedPercent`, Claude `utilization`), so that is what crosses the wire; the UI
always says "N% left" because that is the question being asked. The bar still fills with
consumption, matching the context meter directly above it.
8. **Labels are strings, not an enum.** The set is open — Claude's per-model bucket names come
from the server, and Codex names its windows only by `windowDurationMins` (300 → "5 hour",
10080 → "Weekly"). `windows` is a `ForwardCompatibleArray` so an older client drops a
bucket it cannot render instead of failing the whole config decode.
9. **No self-ticking clock.** The reset countdown re-reads `Date.now()` only when a fresh
snapshot arrives, never on a timer — a continuously repainting meter in the composer is
exactly the GPU cost this app avoids.
10. **Cursor, Grok and OpenCode report nothing**, so they show nothing. This matches the
existing usage page, which is already scoped to `UsageProviderKind = ["claude", "codex"]`.

**Mobile carries the data but not the UI.** `ServerProvider` reaches mobile unchanged, so
`serverConfig.providers` already has the field; mobile has no context bubble and its own
provider sheets, so rendering it there is separate work.

- **Drop it when:** upstream's `ServerProvider` carries a subscription/rate-limit field, or
`ProviderRuntimeIngestion` grows a case for `account.rate-limits.updated`. Check with
`grep -c subscriptionUsage packages/contracts/src/server.ts` and
`grep -n 'account.rate-limits.updated' apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
against clean upstream. If upstream ships its own version, check decision 3 against it first —
bounding the Claude request with `Effect.timeoutOption` alone is the natural thing to write
and it hangs.
- **Browser-only:** the picker footer and the Subscription section of the context bubble.

## 4. Superseded changes

Changes the fork used to carry that upstream has since implemented. **Do not re-introduce them.**
Expand Down
89 changes: 88 additions & 1 deletion apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,26 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
"const lines = createInterface({ input: process.stdin });",
'lines.on("line", (line) => {',
" const message = JSON.parse(line);",
' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',
' if (message.type !== "control_request") return;',
' if (message.request?.subtype === "get_usage") {',
Comment thread
jmclaren7 marked this conversation as resolved.
" process.stdout.write(JSON.stringify({",
' type: "control_response",',
" response: {",
' subtype: "success",',
" request_id: message.request_id,",
" response: {",
' subscription_type: "max",',
" rate_limits_available: true,",
" rate_limits: {",
" five_hour: { utilization: 25, resets_at: null },",
' model_scoped: [{ display_name: "Fable", utilization: 60, resets_at: null }],',
" },",
" },",
" },",
' }) + "\\n");',
" return;",
" }",
' if (message.request?.subtype !== "initialize") return;',
" process.stdout.write(JSON.stringify({",
' type: "control_response",',
" response: {",
Expand Down Expand Up @@ -128,6 +147,14 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
tokenSource: "oauth",
apiKeySource: undefined,
apiProvider: undefined,
subscriptionUsageResponse: {
subscription_type: "max",
rate_limits_available: true,
rate_limits: {
five_hour: { utilization: 25, resets_at: null },
model_scoped: [{ display_name: "Fable", utilization: 60, resets_at: null }],
},
},
slashCommands: [
{
name: "review",
Expand Down Expand Up @@ -161,4 +188,64 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
assert.equal(flagSettings.disableAllHooks, true);
}).pipe(Effect.scoped),
);

it.effect(
"completes without a usage snapshot when a subscribed CLI ignores get_usage",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-no-usage-" });
const executablePath = path.join(tempDir, "fake-claude.mjs");
const workspaceCwd = path.join(tempDir, "workspace");
yield* fs.makeDirectory(workspaceCwd, { recursive: true });

// An older CLI: it reports a subscription, so the probe does ask for
// usage, but it never answers that request. Without the deadline inside
// `readClaudeUsageSnapshot` the promise never settles and the probe
// hangs — `Effect.timeoutOption` cannot interrupt it.
yield* fs.writeFileString(
executablePath,
[
"#!/usr/bin/env node",
'import { createInterface } from "node:readline";',
"const lines = createInterface({ input: process.stdin });",
'lines.on("line", (line) => {',
" const message = JSON.parse(line);",
' if (message.type !== "control_request") return;',
' if (message.request?.subtype !== "initialize") return;',
" process.stdout.write(JSON.stringify({",
' type: "control_response",',
" response: {",
' subtype: "success",',
" request_id: message.request_id,",
" response: {",
" commands: [],",
" agents: [],",
' output_style: "default",',
' available_output_styles: ["default"],',
" models: [],",
' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },',
" },",
" },",
' }) + "\\n");',
"});",
"setInterval(() => {}, 1_000);",
"",
].join("\n"),
);
yield* fs.chmod(executablePath, 0o755);

const capabilities = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: executablePath }),
{ ...process.env },
workspaceCwd,
);

// The probe still resolves, with auth intact and no usage.
assert.equal(capabilities?.subscriptionType, "pro");
assert.equal(capabilities?.subscriptionUsageResponse, undefined);
}).pipe(Effect.scoped),
{ timeout: 30_000 },
);
});
141 changes: 108 additions & 33 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
spawnAndCollect,
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { normalizeClaudeSubscriptionUsage } from "../providerSubscriptionUsage.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment, resolveClaudeConfigDirectory } from "../Drivers/ClaudeHome.ts";
import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
Expand Down Expand Up @@ -697,6 +698,13 @@ type ClaudeCapabilitiesProbe = {
*/
readonly apiProvider: string | undefined;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
/**
* Raw structured `/usage` response, normalized by the caller so the timestamp
* comes from the Effect clock rather than this promise. Absent when the CLI is
* too old to answer, the session has no plan limits (API key, Bedrock,
* Vertex), or the request timed out.
*/
readonly subscriptionUsageResponse?: unknown;
};

function parseClaudeInitializationCommands(
Expand Down Expand Up @@ -771,6 +779,61 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
});
}

/** How long to wait for the structured `/usage` answer before giving up on it. */
const CLAUDE_USAGE_REQUEST_TIMEOUT_MS = 3_000;

type ClaudeUsageCapableQuery = {
readonly usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET?: () => Promise<unknown>;
};

/**
* The SDK's structured `/usage` request, which is where every plan window comes
* from in one answer — the streaming `rate_limit_event` only ever names the one
* window that just moved.
*
* Called through a structural cast rather than the SDK's own type on purpose.
* The method is flagged experimental and is documented to be renamed when it
* stabilizes, and the fields it returns (`model_scoped`, notably, which carries
* the per-model weekly buckets) are added server-side ahead of the npm types.
* Reading it as `unknown` and validating in the normalizer keeps this working
* across SDK versions in both directions, and keeps a rename to a compile-time
* non-event: the method goes missing, the probe returns undefined, and the UI
* shows nothing.
*/
async function readClaudeUsageSnapshot(query: unknown, fiberSignal: AbortSignal): Promise<unknown> {
const request = (query as ClaudeUsageCapableQuery | null)
?.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
if (typeof request !== "function") {
return undefined;
}

// A CLI that does not implement this control request never answers it, and a
// promise that never settles cannot be interrupted out of — an enclosing
// `Effect.timeoutOption` hangs with it rather than cutting it loose. So the
// deadline has to live inside the promise. `AbortSignal.timeout` supplies it
// without a bare timer, and the fiber's own signal is folded in so an
// interrupt upstream releases this too.
const deadline = AbortSignal.any([
fiberSignal,
AbortSignal.timeout(CLAUDE_USAGE_REQUEST_TIMEOUT_MS),
]);

try {
return await Promise.race([
request.call(query),
new Promise<undefined>((resolve) => {
if (deadline.aborted) {
resolve(undefined);
return;
}
deadline.addEventListener("abort", () => resolve(undefined), { once: true });
}),
]);
} catch {
return undefined;
}
}

/**
* Probe account information by spawning a lightweight Claude Agent SDK
* session and reading the initialization result.
Expand All @@ -796,40 +859,47 @@ const probeClaudeCapabilities = (
claudeSettings.binaryPath,
claudeEnvironment,
);
return yield* Effect.tryPromise(async () => {
const q = claudeQuery({
// Never yield — we only need initialization data, not a conversation.
// This prevents any prompt from reaching the Anthropic API.
// oxlint-disable-next-line require-yield
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
await waitForAbortSignal(abort.signal);
})(),
options: buildClaudeCapabilitiesProbeQueryOptions({
executablePath,
abortController: abort,
environment: claudeEnvironment,
cwd,
}),
});
const init = await q.initializationResult();
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiKeySource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiKeySource: account?.apiKeySource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
} satisfies ClaudeCapabilitiesProbe;
const q = claudeQuery({
// Never yield — we only need initialization data, not a conversation.
// This prevents any prompt from reaching the Anthropic API.
// oxlint-disable-next-line require-yield
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
await waitForAbortSignal(abort.signal);
})(),
options: buildClaudeCapabilitiesProbeQueryOptions({
executablePath,
abortController: abort,
environment: claudeEnvironment,
cwd,
}),
});
const init = yield* Effect.tryPromise(() => q.initializationResult());
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiKeySource?: string;
readonly apiProvider?: string;
}
| undefined;
// Only a claude.ai plan has windows to report. Asking anyway would cost an
// API-key, Bedrock or logged-out instance the full request deadline on
// every refresh to learn what `subscriptionType` already said.
const usageResponse = account?.subscriptionType
? yield* Effect.tryPromise((signal) => readClaudeUsageSnapshot(q, signal)).pipe(
Effect.orElseSucceed(() => undefined),
)
: undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiKeySource: account?.apiKeySource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
subscriptionUsageResponse: usageResponse,
} satisfies ClaudeCapabilitiesProbe;
}).pipe(
Effect.ensuring(
Effect.sync(() => {
Expand Down Expand Up @@ -1034,6 +1104,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
});
}

const subscriptionUsage = normalizeClaudeSubscriptionUsage({
response: capabilities.subscriptionUsageResponse,
collectedAt: checkedAt,
});
const authMetadata =
claudeAuthMetadata({
subscriptionType: capabilities.subscriptionType,
Expand All @@ -1047,6 +1121,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
slashCommands: dedupedSlashCommands,
skills,
configDirectory,
...(subscriptionUsage ? { subscriptionUsage } : {}),
probe: {
installed: true,
version: parsedVersion,
Expand Down
Loading
Loading