opencode-openai-quota 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arturo Buendia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,293 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ <h1 align="center">OpenAI Quota for OpenCode 2</h1>
2
+
3
+ <p align="center">Codex subscription quota in your OpenCode 2 sidebar — read from your local Codex CLI, with no tokens copied and no inference spent.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/opencode-openai-quota"><img src="https://img.shields.io/npm/v/opencode-openai-quota?style=flat-square&label=npm&color=cb3837" alt="npm version"></a>
7
+ <a href="#requirements"><img src="https://img.shields.io/badge/OpenCode-2%20TUI%20plugin-7357d5?style=flat-square" alt="OpenCode 2 TUI plugin"></a>
8
+ <a href="#how-it-reads-quota"><img src="https://img.shields.io/badge/Quota-local%20Codex%20app--server-159b83?style=flat-square" alt="Quota from the local Codex app-server"></a>
9
+ <a href="#privacy-and-network-behavior"><img src="https://img.shields.io/badge/Tokens-never%20touched-0b7285?style=flat-square" alt="Tokens are never read"></a>
10
+ </p>
11
+
12
+ <p align="center"><code>opencode plugin add opencode-openai-quota</code></p>
13
+
14
+ <p align="center"><a href="#quick-install"><strong>Other install options ↓</strong></a></p>
15
+
16
+ <p align="center">
17
+ <img src="docs/assets/quota-preview.svg" alt="Recreation of the OpenCode 2 quota panel showing a 5-hour session window with 68 percent remaining, a weekly window with 9 percent remaining in red, two available banked resets, and the next reported expiration." width="440">
18
+ </p>
19
+
20
+ <p align="center"><sub>SVG recreation of the panel with example values—not a captured screenshot. Colors come from your theme; balances at or below 25% turn amber and at or below 10% turn red.</sub></p>
21
+
22
+ ## What it does
23
+
24
+ Adds a small panel to OpenCode 2's sidebar with the OpenAI Codex subscription windows that are active on your machine:
25
+
26
+ - **Session (5h)** and **Weekly** windows, with the duration derived from the window Codex reports.
27
+ - **Remaining quota**, not used quota. A window with `63% used` renders as `37% remaining`.
28
+ - The **next reset** for each window as a live countdown.
29
+ - **Available banked resets** and their next known expiration.
30
+ - **Freshness**: when the numbers were last read, and an explicit warning when the read failed.
31
+
32
+ It is a quota panel, not an API billing, usage-credit, token, or cost tracker.
33
+
34
+ ## How it reads quota
35
+
36
+ This is the part that changed for OpenCode 2. There is no OAuth token handling and no HTTP call to OpenAI:
37
+
38
+ ```
39
+ OpenCode 2 TUI ──spawn──▶ codex app-server ──JSON-RPC over stdio──▶ account/rateLimits/read
40
+ ```
41
+
42
+ 1. The TUI plugin starts a **short-lived `codex app-server`** process.
43
+ 2. It performs the `initialize` handshake, sends `initialized`, then asks for `account/rateLimits/read`.
44
+ 3. It closes stdin so Codex exits on its own, and kills the child if it lingers.
45
+ 4. The raw payload is normalized defensively: anything missing or malformed is shown as **unavailable** instead of being guessed.
46
+
47
+ No model turn is created, so **checking the panel never consumes quota**. The plugin never reads `auth.json` or any credential snapshot, and it never sends your token anywhere.
48
+
49
+ ## Requirements
50
+
51
+ - **OpenCode 2** with the TUI plugin API (`@opencode/plugin/tui`).
52
+ - **Codex CLI** installed and logged in with the ChatGPT account whose quota you want to see:
53
+
54
+ ```sh
55
+ codex --version
56
+ codex login status
57
+ ```
58
+
59
+ If it is not installed or not logged in, the panel says so instead of showing numbers.
60
+
61
+ > The panel reports the account signed into your **local Codex CLI**. If OpenCode uses a different OpenAI account, the numbers you see are still correct—for the Codex account, not necessarily the OpenCode one.
62
+
63
+ ## Quick install
64
+
65
+ One command from npm; the source options below are for development or offline setups.
66
+
67
+ ### Option A — from npm (recommended)
68
+
69
+ ```sh
70
+ opencode plugin add opencode-openai-quota
71
+ ```
72
+
73
+ This installs the package and configures it globally. The server and TUI parts load together, so there is no config file to edit by hand.
74
+
75
+ ```sh
76
+ opencode plugin add opencode-openai-quota@0.3.0 # pin an exact version
77
+ opencode plugin update # update every package plugin
78
+ opencode plugin update opencode-openai-quota # update only this one
79
+ opencode plugin list # see what is installed
80
+ opencode plugin remove opencode-openai-quota # uninstall
81
+ ```
82
+
83
+ If OpenCode is already running, reload with `/reload`. The panel appears in the next session's sidebar.
84
+
85
+ ### Option B — installer script (Windows, from source)
86
+
87
+ ```powershell
88
+ git clone https://github.com/arturobuendia/opencode-openai-quota.git
89
+ cd opencode-openai-quota
90
+ .\install.ps1
91
+ ```
92
+
93
+ The script copies the plugin into OpenCode's global discovery folder:
94
+
95
+ ```text
96
+ <config>/plugins/openai-quota/
97
+ ├── index.ts # server part
98
+ ├── tui.tsx # TUI part
99
+ ├── package.json
100
+ ├── tsconfig.json
101
+ └── src/ # codex.js, quota.js, refresh.js, index.ts, tui.tsx
102
+ ```
103
+
104
+ `<config>` is `%USERPROFILE%\.config\opencode` on Windows, `~/.config/opencode` (or `$XDG_CONFIG_HOME/opencode`) on macOS/Linux, or the directory selected by `OPENCODE_CONFIG_DIR`.
105
+
106
+ An existing install is moved to `<config>/openai-quota-backups/` before the copy—never inside `plugins/`, where OpenCode would discover both copies. The script does not touch `opencode.json`, `cli.json`, `auth.json`, or your other plugins.
107
+
108
+ ### Option C — manual, any platform
109
+
110
+ Copy the same files into the discovery folder:
111
+
112
+ ```sh
113
+ mkdir -p ~/.config/opencode/plugins/openai-quota
114
+ cp -R src index.ts tui.tsx tsconfig.json package.json ~/.config/opencode/plugins/openai-quota/
115
+ ```
116
+
117
+ Or point OpenCode at the checkout from `opencode.json`, which loads both the server and TUI parts of the package:
118
+
119
+ ```jsonc
120
+ {
121
+ "$schema": "https://opencode.ai/config.json",
122
+ "plugins": [
123
+ "C:/Users/YOU/Projects/opencode-openai-quota"
124
+ ]
125
+ }
126
+ ```
127
+
128
+ `file:///C:/Users/YOU/Projects/opencode-openai-quota` and relative paths such as `./plugins/openai-quota` are accepted too. Use `cli.json` instead when the TUI talks to a **remote** server and the plugin must run locally:
129
+
130
+ ```json
131
+ {
132
+ "plugins": ["C:/Users/YOU/Projects/opencode-openai-quota"]
133
+ }
134
+ ```
135
+
136
+ Keep the checkout in place after configuring it. OpenCode reloads plugins when configuration changes; if the panel does not appear, run `/reload`.
137
+
138
+ ### Option D — install with an AI agent
139
+
140
+ Paste this into any coding agent with shell access. It installs the published package, so no file is copied and no credential is touched.
141
+
142
+ ```text
143
+ Install the "OpenAI Quota for OpenCode 2" plugin on this machine.
144
+
145
+ Rules for this task:
146
+ - Do not read, print, or copy any OAuth token, auth.json, or credential store.
147
+ - Do not hand-edit opencode.json, cli.json, auth.json, the OpenCode database, or other plugins.
148
+ - Ask before installing system packages or changing PATH.
149
+ - Stop and report instead of guessing when a prerequisite is missing.
150
+
151
+ Steps:
152
+ 1. Check that `opencode --version` reports OpenCode 2. If it is v1 or missing, stop.
153
+ 2. Check the Codex CLI with `codex --version`, then `codex login status`. If Codex is
154
+ missing or not logged in to a ChatGPT account, stop and report what I have to run.
155
+ 3. Install the plugin with `opencode plugin add opencode-openai-quota`. If that command
156
+ fails, report the exact error and stop; do not clone the repository or copy files as a
157
+ workaround.
158
+ 4. Confirm with `opencode plugin list` that the plugin is listed.
159
+ 5. Reload OpenCode with `/reload`, or tell me to restart the TUI if the sidebar does not
160
+ refresh.
161
+ 6. Verify: the session sidebar must show an "OpenAI Quota" panel. If it says
162
+ "Cannot read Codex quota", report the exact message under it and stop.
163
+
164
+ Finish by reporting the installed version and whether the panel rendered.
165
+ ```
166
+
167
+ The prompt never asks for your token and never hand-edits your configuration. If an agent proposes reading `auth.json` or writing config files directly, it went off-script—stop it and point it back here.
168
+
169
+ ## What appears in the sidebar
170
+
171
+ ```text
172
+ OpenAI Quota
173
+ Updated just now
174
+
175
+ Session (5h)
176
+ ━━━━━━━━━━━━━━━━───────
177
+ 68% remaining
178
+ Resets in 4h 12m
179
+
180
+ Weekly
181
+ ━━━────────────────────
182
+ 9% remaining
183
+ Resets in 2d 7h
184
+
185
+ ───────────────────────
186
+ 2 available banked resets
187
+ Next expires in 9d
188
+ ```
189
+
190
+ - **Window titles** follow the duration Codex reports: `Session (5h)` and `Weekly` for the usual windows, otherwise the generic name plus the real duration.
191
+ - **Balances** are remaining percentages; the bar length follows the value, so a tiny nonzero balance is still visibly nonzero.
192
+ - **Resets** are relative (`in 3d 4h`), recalculated every 30 seconds. A window the service gives no reset time for says `Reset unavailable`.
193
+ - **Banked resets** are manual redemption benefits, separate from automatic resets and purchased credits. The panel shows a genuine `0`, says `Banked resets: unavailable` when the count is missing or malformed, and only shows an expiration when every available reset has a complete, valid timestamp. It never redeems anything. See [OpenAI's reset guide](https://help.openai.com/en/articles/20001498-how-resets-work-in-codex) for the distinctions and offer terms.
194
+
195
+ ### Reading the status line
196
+
197
+ | Panel content | Meaning |
198
+ | --- | --- |
199
+ | `Updated just now` / `Updated 4m ago` | Time of the last successful read. |
200
+ | `Checking · Updated 4m ago` | A read is running; the previous numbers stay visible. |
201
+ | `Reading Codex…` | First read in progress, nothing to show yet. |
202
+ | `Unavailable` | No successful read yet and none in flight. |
203
+ | `Refresh failed · showing saved data` | The last read failed; the previous good numbers stay on screen. |
204
+ | `Cannot read Codex quota` | No usable numbers yet plus the reason, for example Codex missing from `PATH`, not logged in, or the 15-second timeout. |
205
+ | `Try /quota-refresh` | The suggested next step while nothing is displayed. |
206
+
207
+ ## When it refreshes
208
+
209
+ - On startup.
210
+ - **7 seconds after a session execution succeeds**, so Codex can publish the new numbers first.
211
+ - Every **2 minutes**, as a safety net.
212
+ - Immediately when a displayed reset time has passed, so a reset window does not sit at 0% until the next poll.
213
+ - The countdown text alone re-renders every **30 seconds**; the panel does not re-read Codex for that.
214
+
215
+ Automatic attempts respect a **60-second cooldown**, and overlapping requests share a single read instead of starting a second `codex` process. A manual refresh skips the cooldown; reset-triggered and post-execution reads wait for it like any other automatic attempt.
216
+
217
+ ### Manual refresh
218
+
219
+ - Command palette: **Refresh OpenAI quota** (group **OpenAI quota**).
220
+ - Slash command: **`/quota-refresh`**.
221
+
222
+ ## Privacy and network behavior
223
+
224
+ - The plugin makes **no HTTP requests of its own** and holds **no credentials**.
225
+ - It does not read `auth.json`, `OPENCODE_AUTH_CONTENT`, or any token file.
226
+ - The only side effect is a local child process per read: `codex app-server`, with a 15-second timeout, stdin closed to let it exit, and a kill if it does not.
227
+ - Codex `stderr` is drained but never rendered, so diagnostics that may contain private data cannot leak into the sidebar.
228
+ - Errors shown in the panel never include tokens or full command output.
229
+
230
+ ## Configuration
231
+
232
+ | Variable | Default | Purpose |
233
+ | --- | --- | --- |
234
+ | `OPENCODE_QUOTA_CODEX` | `codex` | Command used to start Codex. Point it at another executable or shim. On Windows it runs through the shell, so only use a value you trust. |
235
+
236
+ ## Compatibility
237
+
238
+ Targets **OpenCode 2** and its built-in TUI plugin API. The published package declares `@opencode/plugin` as a dependency and `@opentui/core`, `@opentui/solid`, and `solid-js` as peers, so OpenCode supplies the rendering runtime and you install nothing by hand. No `engines.opencode` range is declared; older or newer TUI APIs are not guaranteed.
239
+
240
+ ## Troubleshooting
241
+
242
+ - **No panel:** confirm `opencode plugin list` shows the plugin (or that a source install exists at `<config>/plugins/openai-quota`), then run `/reload`.
243
+ - **`Cannot read Codex quota`:** read the message under it. `codex` missing from `PATH` → install the Codex CLI. Not logged in → run `codex login`. Timeout → the app-server took longer than 15 seconds.
244
+ - **Numbers look wrong:** check which account the local CLI is using with `codex login status`. The panel follows Codex, not OpenCode.
245
+ - **Verify outside OpenCode:** `npm run probe` prints the same snapshot the panel uses, or fails with the underlying reason.
246
+ - **Banked reset expiration says unavailable:** the count can still be right while the detail list is missing, incomplete, or uses an unknown expiration. The plugin never infers an expiration from a normal reset time.
247
+ - **No weekly or no session row:** Codex only returned one window. The plugin shows what exists and never invents the other.
248
+
249
+ ## Development
250
+
251
+ Node.js **20 or newer** is required for the development scripts. The package has one runtime dependency (`@opencode/plugin`) plus the OpenTUI peers that OpenCode provides.
252
+
253
+ ```sh
254
+ npm test # offline suite: codex handshake, normalization, refresh, labels
255
+ npm run probe # one real read through your local codex app-server
256
+ npm run check # syntax-check the source modules, then run the suite
257
+ npm pack --dry-run # inspect exactly what would be published
258
+ ```
259
+
260
+ Tests use a synthetic Codex fixture (`test/fake-codex.mjs`) and injected clocks, so they stay offline and never read real credentials.
261
+
262
+ | Path | Role |
263
+ | --- | --- |
264
+ | `src/index.ts` | Server part. Registers no resources; the work happens in the TUI. |
265
+ | `src/tui.tsx` | Panel, sidebar slot, `/quota-refresh` command, scheduling, and disposal. |
266
+ | `src/codex.js` | Spawns `codex app-server` and runs the JSON-RPC handshake. |
267
+ | `src/quota.js` | Normalizes the payload; formats percentages, bars, and relative times. |
268
+ | `src/refresh.js` | Cooldown and coalescing so only one read runs at a time. |
269
+ | `install.ps1`, `uninstall.ps1` | Source checkout install/removal for the discovery folder. |
270
+
271
+ ## Publishing (maintainers)
272
+
273
+ ```sh
274
+ npm login # once per machine
275
+ npm run check # also runs automatically via prepublishOnly
276
+ npm version patch|minor|major # bumps package.json, commits, tags
277
+ npm publish --access public
278
+ git push --follow-tags
279
+ ```
280
+
281
+ The `files` field limits the tarball to `src/`, the two root entrypoints, and `tsconfig.json`; tests, scripts, installers, and assets stay in the repository. Pinned installs keep their version, and `opencode plugin update` moves unpinned ones forward.
282
+
283
+ ## Uninstall
284
+
285
+ ```sh
286
+ opencode plugin remove opencode-openai-quota
287
+ ```
288
+
289
+ If you installed from a source checkout instead, run `.\uninstall.ps1`, which removes only `<config>/plugins/openai-quota`. If you configured a path in `opencode.json`/`cli.json`, delete that entry. Run `/reload` afterwards; backups under `<config>/openai-quota-backups/` are yours to delete.
290
+
291
+ ## License
292
+
293
+ MIT. See [LICENSE](LICENSE).
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
- {
2
- "name": "opencode-openai-quota",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "opencode-openai-quota",
3
+ "version": "0.3.1",
4
+ "description": "OpenAI Codex subscription quota in your OpenCode 2 sidebar, read locally from the Codex CLI.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./tui": "./src/tui.tsx"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "index.ts",
14
+ "tui.tsx",
15
+ "tsconfig.json"
16
+ ],
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "openai",
21
+ "codex",
22
+ "quota",
23
+ "tui"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/arturobuendia/opencode-openai-quota.git"
28
+ },
29
+ "homepage": "https://github.com/arturobuendia/opencode-openai-quota#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/arturobuendia/opencode-openai-quota/issues"
32
+ },
33
+ "dependencies": {
34
+ "@opencode/plugin": "^2.2.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@opentui/core": ">=0.6.6",
38
+ "@opentui/solid": ">=0.6.6",
39
+ "solid-js": ">=1.9.0"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test test/*.test.mjs",
43
+ "probe": "node scripts/probe.mjs",
44
+ "check": "node --check src/codex.js && node --check src/quota.js && node --check src/refresh.js && npm test",
45
+ "prepublishOnly": "npm run check"
46
+ }
47
+ }
package/src/codex.js ADDED
@@ -0,0 +1,98 @@
1
+ import { spawn } from "node:child_process"
2
+ import { createInterface } from "node:readline"
3
+
4
+ const quoteForShell = (value) => /[\s"]/u.test(value) ? `"${value.replace(/"/g, '""')}"` : value
5
+
6
+ /**
7
+ * One short-lived local Codex app-server per refresh.
8
+ * No credential files are read. No inference turn is created.
9
+ * The protocol is JSON-lines with an initialize handshake before quota lookup.
10
+ */
11
+ export async function readCodexRateLimits(options = {}) {
12
+ const command = options.command ?? process.env.OPENCODE_QUOTA_CODEX ?? "codex"
13
+ const args = options.args ?? ["app-server"]
14
+ const timeoutMs = options.timeoutMs ?? 15_000
15
+ return new Promise((resolve, reject) => {
16
+ let done = false
17
+ let initialized = false
18
+ let child
19
+ let timer
20
+ let lines
21
+ let killTimer
22
+ const finish = (error, value) => {
23
+ if (done) return
24
+ done = true
25
+ clearTimeout(timer)
26
+ lines?.close()
27
+ // End our owned child; it never outlives a refresh.
28
+ // Closing stdin lets Codex exit gracefully; allow a brief grace period
29
+ // before killing our owned process (important for Windows .cmd shims).
30
+ try { child?.stdin?.end() } catch {}
31
+ killTimer = setTimeout(() => { try { child?.kill() } catch {} }, 800)
32
+ killTimer.unref?.()
33
+ if (error) reject(error)
34
+ else resolve(value)
35
+ }
36
+ try {
37
+ // shell on Windows is needed for common npm/bun codex.cmd shims.
38
+ // command and args are constant by default; do not set the custom
39
+ // executable override to arbitrary/untrusted shell text.
40
+ child = process.platform === "win32"
41
+ // Windows shell concatenates tokens, so quote paths such as
42
+ // "C:\Program Files\nodejs\node.exe" and pass no separate args.
43
+ ? spawn([command, ...args].map(quoteForShell).join(" "), {
44
+ stdio: ["pipe", "pipe", "pipe"],
45
+ windowsHide: true,
46
+ shell: true,
47
+ env: process.env,
48
+ })
49
+ : spawn(command, args, {
50
+ stdio: ["pipe", "pipe", "pipe"],
51
+ windowsHide: true,
52
+ env: process.env,
53
+ })
54
+ } catch {
55
+ finish(new Error("Could not start the Codex CLI. Install Codex and check your PATH."))
56
+ return
57
+ }
58
+ timer = setTimeout(() => finish(new Error(`Codex did not answer in time (${Math.round(timeoutMs / 1000)} s).`)), timeoutMs)
59
+ // Drain diagnostics but don't expose potentially private stderr content.
60
+ child.stderr?.resume()
61
+ child.on("error", () => finish(new Error("Codex CLI not found or not executable. Run: codex --version")))
62
+ child.on("close", (code) => {
63
+ if (killTimer) clearTimeout(killTimer)
64
+ if (!done) finish(new Error(`Codex exited before returning quota (exit ${code ?? "unknown"}). Check: codex login status`))
65
+ })
66
+ const send = (value) => {
67
+ if (done || !child.stdin?.writable) return
68
+ child.stdin.write(`${JSON.stringify(value)}\n`)
69
+ }
70
+ lines = createInterface({ input: child.stdout, crlfDelay: Infinity })
71
+ lines.on("line", (line) => {
72
+ if (done || !line.trim()) return
73
+ let msg
74
+ try { msg = JSON.parse(line) } catch { return }
75
+ if (msg.id === 0) {
76
+ if (msg.error) { finish(new Error("Codex rejected the initialization handshake.")); return }
77
+ if (!msg.result) { finish(new Error("Codex did not confirm initialize.")); return }
78
+ initialized = true
79
+ send({ method: "initialized", params: {} })
80
+ send({ id: 1, method: "account/rateLimits/read" })
81
+ } else if (msg.id === 1 && initialized) {
82
+ if (msg.error) {
83
+ finish(new Error("Could not read quota. Check that Codex is logged in to a ChatGPT account."))
84
+ return
85
+ }
86
+ finish(null, msg.result)
87
+ }
88
+ })
89
+ send({
90
+ id: 0,
91
+ method: "initialize",
92
+ params: {
93
+ clientInfo: { name: "opencode_openai_quota", title: "OpenCode OpenAI Quota", version: "0.3.0" },
94
+ // The rate-limit method is documented as stable; no experimental opt-in needed.
95
+ },
96
+ })
97
+ })
98
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // OpenCode V2 package entrypoint. No server-side resources are required:
2
+ // its TUI plugin lives in ./tui and runs against Codex installed LOCALLY.
3
+ import { Plugin } from "@opencode/plugin"
4
+
5
+ export default Plugin.define({
6
+ id: "openai-quota.server",
7
+ setup() {},
8
+ })
package/src/quota.js ADDED
@@ -0,0 +1,98 @@
1
+ /** Normalizes the official account/rateLimits/read response, never guessing missing figures. */
2
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null
3
+ const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : null
4
+ const clamp = (value) => Math.min(100, Math.max(0, value))
5
+
6
+ function asWindow(input) {
7
+ const data = object(input)
8
+ const used = num(data?.usedPercent)
9
+ if (used === null || used < 0 || used > 100) return null
10
+ const minutes = num(data?.windowDurationMins)
11
+ const seconds = num(data?.resetsAt)
12
+ return {
13
+ usedPercent: used,
14
+ remainingPercent: clamp(100 - used),
15
+ windowDurationMins: minutes !== null && minutes > 0 ? minutes : null,
16
+ resetsAt: seconds !== null && seconds > 0 ? seconds * 1000 : null,
17
+ }
18
+ }
19
+
20
+ export function normalizeQuota(payload, now = Date.now()) {
21
+ const result = object(payload)
22
+ if (!result) throw new Error("Codex returned an invalid rate-limit payload")
23
+ const byId = object(result.rateLimitsByLimitId)
24
+ // Codex can return limits for multiple products. Only show Codex's bucket.
25
+ const bucket = object(byId?.codex) ?? object(result.rateLimits)
26
+ if (!bucket) throw new Error("Codex returned no rate limits for this ChatGPT account")
27
+
28
+ const a = asWindow(bucket.primary)
29
+ const b = asWindow(bucket.secondary)
30
+ if (!a && !b) throw new Error("Codex returned no recognizable usage windows")
31
+ const windows = [a, b].filter(Boolean)
32
+ const knownWeekly = windows.find((w) => w.windowDurationMins === 10080) ?? null
33
+ const session = windows.find((w) => w.windowDurationMins === 300)
34
+ ?? windows.filter((w) => w !== knownWeekly).reduce((first, current) =>
35
+ !first || (current.windowDurationMins ?? Infinity) < (first.windowDurationMins ?? Infinity) ? current : first, null)
36
+ const weekly = knownWeekly ?? (windows.length > 1 ? windows.find((w) => w !== session) : null)
37
+ // The caller displays the raw duration when it's not one of Codex's usual windows.
38
+ const credits = object(result.rateLimitResetCredits)
39
+ const count = num(credits?.availableCount)
40
+ const bankedAvailable = count !== null && count >= 0 && Number.isInteger(count) ? count : null
41
+ const detailList = Array.isArray(credits?.credits) ? credits.credits : null
42
+ let nextExpiration = null
43
+ if (bankedAvailable !== null && bankedAvailable > 0 && detailList?.length) {
44
+ const dates = detailList
45
+ .filter((c) => object(c)?.status === "available")
46
+ .map((c) => num(c.expiresAt))
47
+ .filter((v) => v !== null && v > 0)
48
+ // Details can be capped or incomplete, so don't call this the earliest
49
+ // expiration unless ALL available credits are described with valid times.
50
+ if (dates.length === bankedAvailable) nextExpiration = Math.min(...dates) * 1000
51
+ }
52
+ return {
53
+ status: "ok",
54
+ updatedAt: now,
55
+ session,
56
+ weekly,
57
+ bankedAvailable,
58
+ nextExpiration,
59
+ }
60
+ }
61
+
62
+ export function remainingText(epochMillis, now = Date.now()) {
63
+ if (epochMillis === null || !Number.isFinite(epochMillis)) return "unavailable"
64
+ const remaining = Math.max(0, Math.ceil((epochMillis - now) / 60_000))
65
+ if (remaining === 0) return "now"
66
+ const days = Math.floor(remaining / 1440)
67
+ const hours = Math.floor((remaining % 1440) / 60)
68
+ const minutes = remaining % 60
69
+ return "in " + [days && `${days}d`, hours && `${hours}h`, (minutes || (!days && !hours)) && `${minutes}m`].filter(Boolean).join(" ")
70
+ }
71
+
72
+ export function progress(percent, width = 19) {
73
+ if (!Number.isFinite(percent)) return { filled: "", empty: "─".repeat(width) }
74
+ const n = Math.max(0, Math.min(width, Math.ceil((percent / 100) * width)))
75
+ return { filled: "━".repeat(n), empty: "─".repeat(width - n) }
76
+ }
77
+
78
+ export function durationLabel(window, fallback) {
79
+ if (!window) return fallback
80
+ const duration = window.windowDurationMins
81
+ if (duration === 300) return "Session (5h)"
82
+ if (duration === 10080) return "Weekly"
83
+ if (duration && duration % 1440 === 0) return `${fallback} (${duration / 1440}d)`
84
+ if (duration && duration % 60 === 0) return `${fallback} (${duration / 60}h)`
85
+ return fallback
86
+ }
87
+
88
+ /** Friendly UI status, without lying about freshness during a failed read. */
89
+ export function updatedText(updatedAt, now = Date.now()) {
90
+ if (!Number.isFinite(updatedAt)) return "Never updated"
91
+ const minutes = Math.max(0, Math.floor((now - updatedAt) / 60_000))
92
+ if (minutes < 1) return "Updated just now"
93
+ const days = Math.floor(minutes / 1440)
94
+ const hours = Math.floor((minutes % 1440) / 60)
95
+ const mins = minutes % 60
96
+ const duration = [days && `${days}d`, hours && `${hours}h`, mins && `${mins}m`].filter(Boolean).join(" ")
97
+ return `Updated ${duration} ago`
98
+ }
package/src/refresh.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Coalesces concurrent quota reads and enforces a minimum time between
3
+ * automatic attempts. A manual refresh bypasses the cooldown, but never
4
+ * starts another Codex process while one is running.
5
+ */
6
+ export function createQuotaRefresher({ read, now = () => Date.now(), cooldownMs = 60_000 }) {
7
+ let lastAttempt = null
8
+ let pending = null
9
+ return {
10
+ getLastAttempt: () => lastAttempt,
11
+ refresh({ force = false } = {}) {
12
+ if (pending) return pending
13
+ if (!force && lastAttempt !== null && now() - lastAttempt < cooldownMs) {
14
+ return Promise.resolve(false)
15
+ }
16
+ lastAttempt = now()
17
+ pending = Promise.resolve().then(read).then(() => true).finally(() => { pending = null })
18
+ return pending
19
+ },
20
+ }
21
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,183 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { createSignal, Show } from "solid-js"
3
+ import { readCodexRateLimits } from "./codex.js"
4
+ import { createQuotaRefresher } from "./refresh.js"
5
+ import { durationLabel, normalizeQuota, progress, remainingText, updatedText } from "./quota.js"
6
+
7
+ type QuotaWindowData = {
8
+ usedPercent: number
9
+ remainingPercent: number
10
+ windowDurationMins: number | null
11
+ resetsAt: number | null
12
+ }
13
+ type QuotaSnapshot = {
14
+ updatedAt: number
15
+ session: QuotaWindowData | null
16
+ weekly: QuotaWindowData | null
17
+ bankedAvailable: number | null
18
+ nextExpiration: number | null
19
+ }
20
+
21
+ // The TUI plugin executes in the LOCAL terminal. It queries the user's Codex
22
+ // app-server, and never needs the OpenCode OAuth token or a backend RPC server.
23
+ export default Plugin.define({
24
+ id: "openai-quota.tui",
25
+ setup(context) {
26
+ const theme = context.theme
27
+ const [quota, setQuota] = createSignal<QuotaSnapshot | null>(null)
28
+ const [error, setError] = createSignal("")
29
+ const [busy, setBusy] = createSignal(false)
30
+ const [clock, setClock] = createSignal(Date.now())
31
+ let disposed = false
32
+ let eventTimer: ReturnType<typeof setTimeout> | undefined
33
+
34
+ async function readQuota() {
35
+ if (disposed) return
36
+ setBusy(true)
37
+ try {
38
+ const raw = await readCodexRateLimits()
39
+ const snapshot = normalizeQuota(raw) as QuotaSnapshot
40
+ if (!disposed) {
41
+ setQuota(snapshot)
42
+ setError("")
43
+ setClock(Date.now())
44
+ }
45
+ } catch (cause) {
46
+ if (!disposed) setError(cause instanceof Error ? cause.message : "Codex quota unavailable")
47
+ } finally {
48
+ if (!disposed) setBusy(false)
49
+ }
50
+ }
51
+
52
+ const refresher = createQuotaRefresher({
53
+ read: readQuota,
54
+ cooldownMs: 60_000,
55
+ })
56
+
57
+ function refresh(force = false) {
58
+ return refresher.refresh({ force }).catch(() => false)
59
+ }
60
+
61
+ function QuotaWindow(props: { window: QuotaWindowData; name: string }) {
62
+ const percent = () => Math.round(props.window.remainingPercent)
63
+ const bar = () => progress(props.window.remainingPercent, 23)
64
+ const fg = () => percent() <= 10 ? "#F87171" : percent() <= 25 ? "#FBBF24" : theme.text.base
65
+ const resetLabel = () => {
66
+ const value = remainingText(props.window.resetsAt, clock())
67
+ return value === "unavailable" ? "Reset unavailable" : `Resets ${value}`
68
+ }
69
+ return (
70
+ <box flexDirection="column" paddingTop={1}>
71
+ <text fg={theme.text.base}><b>{durationLabel(props.window, props.name)}</b></text>
72
+ <box flexDirection="row">
73
+ <text fg={fg()}>{bar().filled}</text>
74
+ <text fg={theme.text.muted}>{bar().empty}</text>
75
+ </box>
76
+ <text fg={fg()}>{percent()}% remaining</text>
77
+ <text fg={theme.text.muted}>{resetLabel()}</text>
78
+ </box>
79
+ )
80
+ }
81
+
82
+ function QuotaPanel() {
83
+ const snap = quota
84
+ const label = () => {
85
+ const data = snap()
86
+ if (!data) return busy() ? "Reading Codex…" : "Unavailable"
87
+ return `${busy() ? "Checking · " : ""}${updatedText(data.updatedAt, clock())}`
88
+ }
89
+ return (
90
+ <box flexDirection="column" paddingTop={1} paddingBottom={1}>
91
+ <text fg={theme.text.base}><b>OpenAI Quota</b></text>
92
+ <text fg={theme.text.muted}>{label()}</text>
93
+ <Show when={snap()}>
94
+ {(snapshot) => (
95
+ <>
96
+ <Show when={snapshot().session}>
97
+ {(value) => <QuotaWindow window={value()} name="Session" />}
98
+ </Show>
99
+ <Show when={snapshot().weekly}>
100
+ {(value) => <QuotaWindow window={value()} name="Weekly" />}
101
+ </Show>
102
+ <box flexDirection="column" paddingTop={1}>
103
+ <text fg={theme.text.muted}>───────────────────────</text>
104
+ <text fg={theme.text.base}>
105
+ {snapshot().bankedAvailable === null
106
+ ? "Banked resets: unavailable"
107
+ : `${snapshot().bankedAvailable} available banked resets`}
108
+ </text>
109
+ <text fg={theme.text.muted}>
110
+ {snapshot().nextExpiration
111
+ ? `Next expires ${remainingText(snapshot().nextExpiration, clock())}`
112
+ : "Next expiration unavailable"}
113
+ </text>
114
+ </box>
115
+ </>
116
+ )}
117
+ </Show>
118
+ <Show when={Boolean(error())}>
119
+ <box flexDirection="column" paddingTop={1}>
120
+ <text fg="#FBBF24">{snap() ? "Refresh failed · showing saved data" : "Cannot read Codex quota"}</text>
121
+ <Show when={!snap()}>
122
+ <text fg={theme.text.muted}>{error()}</text>
123
+ <text fg={theme.text.muted}>Try /quota-refresh</text>
124
+ </Show>
125
+ </box>
126
+ </Show>
127
+ </box>
128
+ )
129
+ }
130
+
131
+ const removeSidebar = context.ui.slot({
132
+ append: "sidebar.content",
133
+ render: () => <QuotaPanel />,
134
+ })
135
+
136
+ // These commands belong only to this plugin; never shadow other statusline plugins.
137
+ context.keymap.layer(() => ({
138
+ mode: "global",
139
+ commands: [{
140
+ id: "openai-quota.refresh",
141
+ title: "Refresh OpenAI quota",
142
+ group: "OpenAI quota",
143
+ palette: true,
144
+ slash: { name: "quota-refresh" },
145
+ run: () => { void refresh(true) },
146
+ }],
147
+ }))
148
+
149
+ // Refresh after a completed OpenCode execution. Let Codex publish the new
150
+ // numbers for a few seconds first, then enforce a one-minute cooldown.
151
+ const stopExecution = context.data.on("session.execution.succeeded", () => {
152
+ if (eventTimer) clearTimeout(eventTimer)
153
+ eventTimer = setTimeout(() => {
154
+ eventTimer = undefined
155
+ if (!disposed) void refresh()
156
+ }, 7_000)
157
+ })
158
+
159
+ // Existing safety net: periodic reads, live relative times, no per-render IO.
160
+ void refresh()
161
+ const polling = setInterval(() => { if (!disposed) void refresh() }, 120_000)
162
+ const ticking = setInterval(() => {
163
+ if (disposed) return
164
+ const now = Date.now()
165
+ setClock(now)
166
+ const snapshot = quota()
167
+ // Prevent showing a zero-quota window indefinitely after it resets.
168
+ if (snapshot && [snapshot.session?.resetsAt, snapshot.weekly?.resetsAt]
169
+ .some((when) => when !== null && when !== undefined && when <= now)) {
170
+ void refresh()
171
+ }
172
+ }, 30_000)
173
+
174
+ return () => {
175
+ disposed = true
176
+ clearInterval(polling)
177
+ clearInterval(ticking)
178
+ if (eventTimer) clearTimeout(eventTimer)
179
+ stopExecution()
180
+ removeSidebar?.()
181
+ }
182
+ },
183
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "react-jsx",
7
+ "jsxImportSource": "@opentui/solid",
8
+ "allowJs": true,
9
+ "allowImportingTsExtensions": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true
12
+ }
13
+ }
package/tui.tsx ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/tui.tsx"