opencode-openai-quota 0.0.0-stage → 0.3.0

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,290 @@
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="#requirements"><img src="https://img.shields.io/badge/OpenCode-2%20TUI%20plugin-7357d5?style=flat-square" alt="OpenCode 2 TUI plugin"></a>
7
+ <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>
8
+ <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>
9
+ </p>
10
+
11
+ <p align="center"><a href="#quick-install"><strong>Quick install ↓</strong></a></p>
12
+
13
+ <p align="center">
14
+ <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">
15
+ </p>
16
+
17
+ <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>
18
+
19
+ ## What it does
20
+
21
+ Adds a small panel to OpenCode 2's sidebar with the OpenAI Codex subscription windows that are active on your machine:
22
+
23
+ - **Session (5h)** and **Weekly** windows, with the duration derived from the window Codex reports.
24
+ - **Remaining quota**, not used quota. A window with `63% used` renders as `37% remaining`.
25
+ - The **next reset** for each window as a live countdown.
26
+ - **Available banked resets** and their next known expiration.
27
+ - **Freshness**: when the numbers were last read, and an explicit warning when the read failed.
28
+
29
+ It is a quota panel, not an API billing, usage-credit, token, or cost tracker.
30
+
31
+ ## How it reads quota
32
+
33
+ This is the part that changed for OpenCode 2. There is no OAuth token handling and no HTTP call to OpenAI:
34
+
35
+ ```
36
+ OpenCode 2 TUI ──spawn──▶ codex app-server ──JSON-RPC over stdio──▶ account/rateLimits/read
37
+ ```
38
+
39
+ 1. The TUI plugin starts a **short-lived `codex app-server`** process.
40
+ 2. It performs the `initialize` handshake, sends `initialized`, then asks for `account/rateLimits/read`.
41
+ 3. It closes stdin so Codex exits on its own, and kills the child if it lingers.
42
+ 4. The raw payload is normalized defensively: anything missing or malformed is shown as **unavailable** instead of being guessed.
43
+
44
+ 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.
45
+
46
+ ## Requirements
47
+
48
+ - **OpenCode 2** with the TUI plugin API (`@opencode/plugin/tui`).
49
+ - **Codex CLI** installed and logged in with the ChatGPT account whose quota you want to see:
50
+
51
+ ```sh
52
+ codex --version
53
+ codex login status
54
+ ```
55
+
56
+ If it is not installed or not logged in, the panel says so instead of showing numbers.
57
+
58
+ > 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.
59
+
60
+ ## Quick install
61
+
62
+ One command from npm; the source options below are for development or offline setups.
63
+
64
+ ### Option A — from npm (recommended)
65
+
66
+ ```sh
67
+ opencode plugin add opencode-openai-quota
68
+ ```
69
+
70
+ 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.
71
+
72
+ ```sh
73
+ opencode plugin add opencode-openai-quota@0.3.0 # pin an exact version
74
+ opencode plugin update # update every package plugin
75
+ opencode plugin update opencode-openai-quota # update only this one
76
+ opencode plugin list # see what is installed
77
+ opencode plugin remove opencode-openai-quota # uninstall
78
+ ```
79
+
80
+ If OpenCode is already running, reload with `/reload`. The panel appears in the next session's sidebar.
81
+
82
+ ### Option B — installer script (Windows, from source)
83
+
84
+ ```powershell
85
+ git clone https://github.com/arturobuendia/opencode-openai-quota.git
86
+ cd opencode-openai-quota
87
+ .\install.ps1
88
+ ```
89
+
90
+ The script copies the plugin into OpenCode's global discovery folder:
91
+
92
+ ```text
93
+ <config>/plugins/openai-quota/
94
+ ├── index.ts # server part
95
+ ├── tui.tsx # TUI part
96
+ ├── package.json
97
+ ├── tsconfig.json
98
+ └── src/ # codex.js, quota.js, refresh.js, index.ts, tui.tsx
99
+ ```
100
+
101
+ `<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`.
102
+
103
+ 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.
104
+
105
+ ### Option C — manual, any platform
106
+
107
+ Copy the same files into the discovery folder:
108
+
109
+ ```sh
110
+ mkdir -p ~/.config/opencode/plugins/openai-quota
111
+ cp -R src index.ts tui.tsx tsconfig.json package.json ~/.config/opencode/plugins/openai-quota/
112
+ ```
113
+
114
+ Or point OpenCode at the checkout from `opencode.json`, which loads both the server and TUI parts of the package:
115
+
116
+ ```jsonc
117
+ {
118
+ "$schema": "https://opencode.ai/config.json",
119
+ "plugins": [
120
+ "C:/Users/YOU/Projects/opencode-openai-quota"
121
+ ]
122
+ }
123
+ ```
124
+
125
+ `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:
126
+
127
+ ```json
128
+ {
129
+ "plugins": ["C:/Users/YOU/Projects/opencode-openai-quota"]
130
+ }
131
+ ```
132
+
133
+ Keep the checkout in place after configuring it. OpenCode reloads plugins when configuration changes; if the panel does not appear, run `/reload`.
134
+
135
+ ### Option D — install with an AI agent
136
+
137
+ Paste this into any coding agent with shell access. It installs the published package, so no file is copied and no credential is touched.
138
+
139
+ ```text
140
+ Install the "OpenAI Quota for OpenCode 2" plugin on this machine.
141
+
142
+ Rules for this task:
143
+ - Do not read, print, or copy any OAuth token, auth.json, or credential store.
144
+ - Do not hand-edit opencode.json, cli.json, auth.json, the OpenCode database, or other plugins.
145
+ - Ask before installing system packages or changing PATH.
146
+ - Stop and report instead of guessing when a prerequisite is missing.
147
+
148
+ Steps:
149
+ 1. Check that `opencode --version` reports OpenCode 2. If it is v1 or missing, stop.
150
+ 2. Check the Codex CLI with `codex --version`, then `codex login status`. If Codex is
151
+ missing or not logged in to a ChatGPT account, stop and report what I have to run.
152
+ 3. Install the plugin with `opencode plugin add opencode-openai-quota`. If that command
153
+ fails, report the exact error and stop; do not clone the repository or copy files as a
154
+ workaround.
155
+ 4. Confirm with `opencode plugin list` that the plugin is listed.
156
+ 5. Reload OpenCode with `/reload`, or tell me to restart the TUI if the sidebar does not
157
+ refresh.
158
+ 6. Verify: the session sidebar must show an "OpenAI Quota" panel. If it says
159
+ "Cannot read Codex quota", report the exact message under it and stop.
160
+
161
+ Finish by reporting the installed version and whether the panel rendered.
162
+ ```
163
+
164
+ 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.
165
+
166
+ ## What appears in the sidebar
167
+
168
+ ```text
169
+ OpenAI Quota
170
+ Updated just now
171
+
172
+ Session (5h)
173
+ ━━━━━━━━━━━━━━━━───────
174
+ 68% remaining
175
+ Resets in 4h 12m
176
+
177
+ Weekly
178
+ ━━━────────────────────
179
+ 9% remaining
180
+ Resets in 2d 7h
181
+
182
+ ───────────────────────
183
+ 2 available banked resets
184
+ Next expires in 9d
185
+ ```
186
+
187
+ - **Window titles** follow the duration Codex reports: `Session (5h)` and `Weekly` for the usual windows, otherwise the generic name plus the real duration.
188
+ - **Balances** are remaining percentages; the bar length follows the value, so a tiny nonzero balance is still visibly nonzero.
189
+ - **Resets** are relative (`in 3d 4h`), recalculated every 30 seconds. A window the service gives no reset time for says `Reset unavailable`.
190
+ - **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.
191
+
192
+ ### Reading the status line
193
+
194
+ | Panel content | Meaning |
195
+ | --- | --- |
196
+ | `Updated just now` / `Updated 4m ago` | Time of the last successful read. |
197
+ | `Checking · Updated 4m ago` | A read is running; the previous numbers stay visible. |
198
+ | `Reading Codex…` | First read in progress, nothing to show yet. |
199
+ | `Unavailable` | No successful read yet and none in flight. |
200
+ | `Refresh failed · showing saved data` | The last read failed; the previous good numbers stay on screen. |
201
+ | `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. |
202
+ | `Try /quota-refresh` | The suggested next step while nothing is displayed. |
203
+
204
+ ## When it refreshes
205
+
206
+ - On startup.
207
+ - **7 seconds after a session execution succeeds**, so Codex can publish the new numbers first.
208
+ - Every **2 minutes**, as a safety net.
209
+ - Immediately when a displayed reset time has passed, so a reset window does not sit at 0% until the next poll.
210
+ - The countdown text alone re-renders every **30 seconds**; the panel does not re-read Codex for that.
211
+
212
+ 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.
213
+
214
+ ### Manual refresh
215
+
216
+ - Command palette: **Refresh OpenAI quota** (group **OpenAI quota**).
217
+ - Slash command: **`/quota-refresh`**.
218
+
219
+ ## Privacy and network behavior
220
+
221
+ - The plugin makes **no HTTP requests of its own** and holds **no credentials**.
222
+ - It does not read `auth.json`, `OPENCODE_AUTH_CONTENT`, or any token file.
223
+ - 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.
224
+ - Codex `stderr` is drained but never rendered, so diagnostics that may contain private data cannot leak into the sidebar.
225
+ - Errors shown in the panel never include tokens or full command output.
226
+
227
+ ## Configuration
228
+
229
+ | Variable | Default | Purpose |
230
+ | --- | --- | --- |
231
+ | `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. |
232
+
233
+ ## Compatibility
234
+
235
+ 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.
236
+
237
+ ## Troubleshooting
238
+
239
+ - **No panel:** confirm `opencode plugin list` shows the plugin (or that a source install exists at `<config>/plugins/openai-quota`), then run `/reload`.
240
+ - **`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.
241
+ - **Numbers look wrong:** check which account the local CLI is using with `codex login status`. The panel follows Codex, not OpenCode.
242
+ - **Verify outside OpenCode:** `npm run probe` prints the same snapshot the panel uses, or fails with the underlying reason.
243
+ - **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.
244
+ - **No weekly or no session row:** Codex only returned one window. The plugin shows what exists and never invents the other.
245
+
246
+ ## Development
247
+
248
+ 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.
249
+
250
+ ```sh
251
+ npm test # offline suite: codex handshake, normalization, refresh, labels
252
+ npm run probe # one real read through your local codex app-server
253
+ npm run check # syntax-check the source modules, then run the suite
254
+ npm pack --dry-run # inspect exactly what would be published
255
+ ```
256
+
257
+ Tests use a synthetic Codex fixture (`test/fake-codex.mjs`) and injected clocks, so they stay offline and never read real credentials.
258
+
259
+ | Path | Role |
260
+ | --- | --- |
261
+ | `src/index.ts` | Server part. Registers no resources; the work happens in the TUI. |
262
+ | `src/tui.tsx` | Panel, sidebar slot, `/quota-refresh` command, scheduling, and disposal. |
263
+ | `src/codex.js` | Spawns `codex app-server` and runs the JSON-RPC handshake. |
264
+ | `src/quota.js` | Normalizes the payload; formats percentages, bars, and relative times. |
265
+ | `src/refresh.js` | Cooldown and coalescing so only one read runs at a time. |
266
+ | `install.ps1`, `uninstall.ps1` | Source checkout install/removal for the discovery folder. |
267
+
268
+ ## Publishing (maintainers)
269
+
270
+ ```sh
271
+ npm login # once per machine
272
+ npm run check # also runs automatically via prepublishOnly
273
+ npm version patch|minor|major # bumps package.json, commits, tags
274
+ npm publish --access public
275
+ git push --follow-tags
276
+ ```
277
+
278
+ 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.
279
+
280
+ ## Uninstall
281
+
282
+ ```sh
283
+ opencode plugin remove opencode-openai-quota
284
+ ```
285
+
286
+ 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.
287
+
288
+ ## License
289
+
290
+ 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.0",
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"