Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- [Background terminal tasks](docs/async-tasks.md) in AIR, with task status and targeted stop support after capability negotiation.
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md).
- A per-turn [agent file-change report](docs/agent-file-change-report.md) after capability negotiation.
- The account's usage windows and their reset times, pushed as they change through the [rate limits extension](docs/rate-limits-extension.md).
- Client-provided MCP servers over command-based stdio config and HTTP transport.
- Slash commands: `/status`, `/mcp`, `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills.

Expand Down
116 changes: 116 additions & 0 deletions docs/rate-limits-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Rate limits extension

Status: Experimental

The agent pushes the usage windows of the account this connection bills to, so
a client can show how much of a window is spent and, when a turn is refused for
a spent window, knows when that window clears. It is the account-level
companion of the `authStatus` push and has the same shape of contract: push
only, connection-scoped, nothing for the client to request.

## Why

Codex refuses a turn on a spent window with `codexErrorInfo:
"usageLimitExceeded"`, and that error carries no reset time. The reset is
reported on the app-server's `account/rateLimits/updated` notification, which
the agent received but did not forward: it only fed the `/status` command's
text. A client that wants to park the session and resume it when the window
clears — instead of guessing or retrying blind — needs the structured value.
The two arrive as separate messages; a client should treat the latest pushed
windows as the reset to schedule against and not depend on either arriving
first.

## Capability

The `initialize` response advertises the push under
`agentCapabilities._meta.rateLimits` as an empty object. Its presence means
"this agent pushes `_account/rate_limits_update`". It never carries a payload
and gates nothing the client sends.

```json
{
"agentCapabilities": {
"_meta": {
"rateLimits": {}
}
}
}
```

## Notification

Method: `_account/rate_limits_update`

```json
{
"rateLimits": {
"limitId": "codex",
"limitName": "Codex",
"normalModelSlug": null,
"primary": { "usedPercent": 100, "windowDurationMins": 300, "resetsAt": 1789511479 },
"secondary": { "usedPercent": 41, "windowDurationMins": 10080, "resetsAt": 1789841013 },
"rateLimitReachedType": "rate_limit_reached",
"planType": "plus"
}
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `limitId` | string | Stable id of the limit; `"codex"` for the account's ordinary usage limit. |
| `limitName` | string \| null | Human-readable name, when the backend names it. |
| `normalModelSlug` | string \| null | For a model-specific limit, the model whose quota these windows are; `null` for the account's ordinary limit. |
| `primary`, `secondary` | window \| null | The rolling windows; `null` when the backend did not report one. |
| `window.usedPercent` | number | Share of the window already spent, 0–100. |
| `window.windowDurationMins` | integer \| null | Window length in minutes: `300` for the 5-hour window, `10080` for the weekly one. |
| `window.resetsAt` | integer \| null | Unix time in **seconds** at which the window clears. |
| `rateLimitReachedType` | string \| null | The reached state as reported on the latest update: non-null when the backend reported that it is refusing turns on this limit, and why (`rate_limit_reached`, the workspace credit and usage variants); `null` when the latest update reported none. Clients tolerate values they do not recognise. |
| `planType` | string \| null | Vendor plan string, not normalised. |

Field names and units follow codex app-server's `RateLimitSnapshot`.

## When it is pushed

On every app-server `account/rateLimits/updated` whose merged payload for that
`limitId` differs from the last one pushed for it. That notification is sparse —
the app-server asks clients to merge available values into the most recent
snapshot — so the agent merges first, against a connection-level baseline kept
per `limitId`, and pushes the complete picture; a client never needs
`account/rateLimits/read`. `limitName`, `planType` and the other account
metadata carry forward through the merge; the windows are taken as reported
(a `null` window means the update did not report one). Duplicates are
suppressed per `limitId`, including the copies produced when an account-level
notification reaches several open sessions.

A push replaces the client's state **for that `limitId`**. An account can have
more than one limit (each is pushed and deduplicated separately), so a client
keeps a map keyed by `limitId` rather than a single value; `normalModelSlug`
says which model a limit belongs to.

The agent observes the app-server notification once, at the connection,
before it reaches the per-session handlers, so the number of open sessions
neither duplicates nor reorders pushes.

## Account changes

The windows belong to the signed-in account. On a logout, or an `authStatus`
push that reports a different account, the agent drops its baseline and
duplicate filter, so the first update for the new account is pushed even when
its values equal the previous account's. A client should drop the windows it
holds when `authStatus` changes and wait for the next push.

## Which reset to schedule against

`primary.resetsAt` / `secondary.resetsAt` are the rolling usage windows and are
the reset for `rateLimitReachedType: "rate_limit_reached"`. The workspace
variants (`workspace_owner_usage_limit_reached`,
`workspace_member_usage_limit_reached`, and the credit-depletion variants) are
spend controls, not usage windows: this payload carries **no** reset for them,
and a client must not derive one from the rolling windows. Their state remains
readable through the `/status` command.

## What is not forwarded

Credit balances, spend controls and the individual spend limit are billing
state rather than usage windows and are left out. They remain readable through
the `/status` command.
12 changes: 12 additions & 0 deletions src/AcpExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ export {
type AuthStatusUpdateNotification,
} from "./AuthStatusMeta";

export {
RATE_LIMITS_META_KEY,
RATE_LIMITS_UPDATE_METHOD,
rateLimitsCapability,
sameRateLimits,
toRateLimits,
type RateLimits,
type RateLimitsCapability,
type RateLimitsUpdateNotification,
type RateLimitsWindow,
} from "./RateLimitsMeta";

export {
GOAL_CONTROL_ACTIONS,
GOAL_CONTROL_METHOD,
Expand Down
102 changes: 100 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ import type {
Thread,
ThreadGoal,
ThreadItem,
UserInput
} from "./app-server/v2";
UserInput, AccountRateLimitsUpdatedNotification, RateLimitSnapshot} from "./app-server/v2";
import type {RateLimitsMap} from "./RateLimitsMap";
import {ModelId} from "./ModelId";
import {AgentMode, MODE_CONFIG_ID} from "./AgentMode";
Expand Down Expand Up @@ -126,6 +125,20 @@ import {
gatewayStatus,
sameAuthStatus,
} from "./AuthStatusMeta";
import {
RATE_LIMITS_META_KEY,
RATE_LIMITS_UPDATE_METHOD,
rateLimitsCapability,
sameRateLimits,
toRateLimits,
type RateLimits,
} from "./RateLimitsMeta";
import {mergeRateLimitSnapshot} from "./RateLimitsMap";

/** Whether two auth statuses describe the same signed-in identity. */
function sameAccount(previous: AuthStatus, next: AuthStatus): boolean {
return previous.kind === next.kind && previous.account?.email === next.account?.email;
}
import {randomUUID} from "node:crypto";
import {TitleGenerator} from "./TitleGenerator";
import {once} from "node:events";
Expand Down Expand Up @@ -272,6 +285,14 @@ export class CodexAcpServer {
private booleanConfigOptionsSupported: boolean;
/** Last `authStatus` pushed to the client; used to suppress duplicates. */
private currentAuthStatus: AuthStatus | null;
/** Connection-level merge baseline for the sparse app-server
* `account/rateLimits/updated`, per `limitId`. Per-session state cannot be
* the baseline: it is reset on every session create and the account
* notification fans out to every session's handler. */
private readonly rateLimitSnapshots = new Map<string, RateLimitSnapshot>();
/** Last `_account/rate_limits_update` payload pushed per `limitId`; the
* duplicate filter. */
private readonly pushedRateLimits = new Map<string, RateLimits>();

private readonly sessions: Map<string, SessionState>;
private readonly pendingMcpStartupSessions: Map<string, PendingMcpStartupSession>;
Expand Down Expand Up @@ -308,6 +329,7 @@ export class CodexAcpServer {
this.permissionLifecycleContexts = new WeakMap();
this.connection = connection;
this.codexAcpClient = codexAcpClient;
this.observeAccountNotifications(codexAcpClient);
this.defaultAuthRequest = defaultAuthRequest ?? null;
this.codexProcessState = codexProcessState ?? null;
this.captureStderr();
Expand Down Expand Up @@ -379,6 +401,9 @@ export class CodexAcpServer {
// Presence means "this agent pushes `_auth/status_update`". It
// never carries a payload, and the client never asks for one.
[AUTH_STATUS_META_KEY]: authStatusCapability(),
// Presence means "this agent pushes `_account/rate_limits_update`"
// (RateLimitsMeta.ts). Same contract shape as `authStatus`.
[RATE_LIMITS_META_KEY]: rateLimitsCapability(),
},
},
authMethods: getCodexAuthMethods(_params.clientCapabilities),
Expand Down Expand Up @@ -1002,6 +1027,7 @@ export class CodexAcpServer {
async logout(_params: acp.LogoutRequest): Promise<void> {
logger.log("Logout request received");
await this.runWithProcessCheck(() => this.codexAcpClient.logout());
this.forgetRateLimits();
await this.refreshAuthState(null);
logger.log("Logout request completed");
}
Expand Down Expand Up @@ -1050,6 +1076,7 @@ export class CodexAcpServer {
}
await replacement.initialize(this.initializeRequest);
this.codexAcpClient = replacement;
this.observeAccountNotifications(replacement);
this.availableCommands = this.createAvailableCommands(replacement);

const resumeErrors: unknown[] = [];
Expand Down Expand Up @@ -1301,6 +1328,9 @@ export class CodexAcpServer {
if (sameAuthStatus(this.currentAuthStatus, next)) {
return;
}
if (this.currentAuthStatus !== null && !sameAccount(this.currentAuthStatus, next)) {
this.forgetRateLimits();
}
this.currentAuthStatus = next;
try {
await this.connection.notify(AUTH_STATUS_UPDATE_METHOD, {authStatus: next});
Expand All @@ -1309,6 +1339,74 @@ export class CodexAcpServer {
}
}

/**
* Subscribes to account-level app-server notifications at the connection,
* where each arrives exactly once and in receive order. A per-session
* handler would see the same notification once per open session, from
* queues that can reorder the copies, so a stale snapshot could overwrite a
* newer one. Re-run for a replacement client.
*/
private observeAccountNotifications(client: CodexAcpClient): void {
client.appServerClient.onAccountNotification((notification) => {
if (notification.method === "account/rateLimits/updated") {
this.handleRateLimitsUpdated(notification.params);
}
});
}

/**
* Handles the app-server `account/rateLimits/updated` push.
*
* The notification is sparse: the app-server asks clients to merge available
* values into the most recent snapshot. That merge happens HERE, against a
* connection-level baseline per `limitId`, because per-session state is reset
* on every session create and would make a freshly created session report
* `planType: null` for the same account the previous session knew. The
* windows themselves are taken as reported (a `null` window is "not
* reported", not "unchanged"); `limitName` and the account metadata carry
* forward through {@link mergeRateLimitSnapshot}.
*/
handleRateLimitsUpdated(notification: AccountRateLimitsUpdatedNotification): void {
const raw = notification.rateLimits;
const limitId = raw.limitId ?? "codex";
const previous = this.rateLimitSnapshots.get(limitId);
const merged: RateLimitSnapshot = previous
? mergeRateLimitSnapshot(previous, raw)
: {...raw, limitId};
merged.limitName = merged.limitName ?? previous?.limitName ?? null;
this.rateLimitSnapshots.set(limitId, merged);
void this.setRateLimits(toRateLimits(limitId, merged));
}

/**
* Pushes `_account/rate_limits_update` for one limit when its payload changed.
* A push replaces the client's state for that `limitId` only; other limits
* on the account are pushed separately, so a repeat of one limit's windows
* (the fan-out, or a sparse update that added nothing) never goes out.
*/
/**
* The windows belong to the account that is signed in. When that changes
* (logout, or a login reported for a different account) the baseline and
* the duplicate filter are dropped, so the next update for the new account
* is pushed even when its values happen to equal the old account's.
*/
private forgetRateLimits(): void {
this.rateLimitSnapshots.clear();
this.pushedRateLimits.clear();
}

private async setRateLimits(next: RateLimits): Promise<void> {
if (sameRateLimits(this.pushedRateLimits.get(next.limitId) ?? null, next)) {
return;
}
this.pushedRateLimits.set(next.limitId, next);
try {
await this.connection.notify(RATE_LIMITS_UPDATE_METHOD, {rateLimits: next});
} catch (error) {
logger.log("Failed to send rate limits update", {error: String(error)});
}
}

async setSessionMode(
_params: acp.SetSessionModeRequest,
): Promise<acp.SetSessionModeResponse> {
Expand Down
15 changes: 15 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,18 @@ export class CodexAppServerClient {
this.codexEventHandlers.push(callback);
}

/**
* Registers a listener for account-level (thread-less) notifications. Each
* such notification reaches every listener exactly once, in receive order,
* before the per-session fan-out below; a subscriber that needs the
* account's state as one ordered stream uses this rather than a session
* handler, whose asynchronous queue can reorder copies across sessions.
*/
onAccountNotification(callback: (event: ServerNotification) => void) {
this.accountNotificationListeners.push(callback);
}

private accountNotificationListeners: Array<(event: ServerNotification) => void> = [];
private notificationHandlers = new Map<string, (event: ServerNotification) => void>();
private notify(notification: ServerNotification) {
const threadId = extractThreadId(notification);
Expand All @@ -832,6 +844,9 @@ export class CodexAppServerClient {
}
return;
}
for (const listener of this.accountNotificationListeners) {
listener(notification);
}
for (const notificationHandler of this.notificationHandlers.values()) {
notificationHandler(notification);
}
Expand Down
4 changes: 4 additions & 0 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,10 @@ export class CodexEventHandler {
limitName: snapshot.limitName ?? existingEntry?.limitName ?? limitId,
snapshot,
});
// Per-session merge for `/status` only. The `_account/rate_limits_update`
// push is fed from the connection-level listener in CodexAcpServer, not
// from here: this baseline is reset on every session create, and the
// account notification fans out to every session's handler.
}

private handleFuzzyFileSearchSessionUpdated(
Expand Down
Loading