pi-spark 0.18.1 → 0.19.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/README.md CHANGED
@@ -41,6 +41,12 @@ pi-spark shows the active provider's credit balance or rate-limit usage in the s
41
41
 
42
42
  ![Credits](./assets/screenshot-credits.png)
43
43
 
44
+ #### OpenAI Codex banked resets
45
+
46
+ Banked rate-limit resets are saved benefits that can reset eligible Codex usage windows when redeemed. When available, their count appears after the usage windows. Run `/codex-reset` to inspect each reset and its expiration date, then select one to redeem. See [OpenAI Codex Banked Rate-Limit Resets](./docs/openai-codex-banked-rate-limit-resets.md) for the underlying behavior and internal APIs.
47
+
48
+ ![Codex resets](./assets/screenshot-codex-resets.png)
49
+
44
50
  ### Presets
45
51
 
46
52
  pi-spark lets you define named model presets in `spark.json` (see [Configuration](#configuration)), so you can switch between models and thinking levels without retyping provider details. The active preset is shown on the editor's top border.
@@ -0,0 +1,75 @@
1
+ # Background Model Calls and OpenAI Codex Sessions
2
+
3
+ > This document was generated with the [Pi](https://pi.dev/) coding agent.
4
+
5
+ In pi-spark, the title and recap features call the model outside Pi's main agent loop. They send a small, independent conversation through `completeSimple()` and persist only the resulting text and usage.
6
+
7
+ On July 10, 2026, the day GPT-5.6 became available, I configured `openai-codex/gpt-5.6-luna` for title and recap generation while the main thread also used the `openai-codex` provider.
8
+
9
+ ## Problem
10
+
11
+ Two failures appeared.
12
+
13
+ Without a session ID, GPT-5.6 Luna returned no text:
14
+
15
+ ```text
16
+ Codex error: Model not found gpt-5.6-luna-free-1p-codexswic-ev3
17
+ ```
18
+
19
+ Passing the main thread's session ID to `completeSimple()` fixed routing, but made the background call reuse the main Codex WebSocket. Its response changed the socket's server-side continuation state. The next main turn then failed:
20
+
21
+ ```text
22
+ Codex error: Previous response with id 'resp_...' not found.
23
+ ```
24
+
25
+ Using a non-cached WebSocket was not enough: it stopped the background call from writing a client-side continuation, but the physical socket was still shared by session ID.
26
+
27
+ ## Root cause
28
+
29
+ For `openai-codex-responses`, the session ID serves two roles:
30
+
31
+ - Request-routing identity through `session-id` and `x-client-request-id` headers
32
+ - Client-side WebSocket cache key and response-chain identity
33
+
34
+ A background call therefore needs a valid Codex session ID, but it must not reuse the main thread's ID.
35
+
36
+ In our tests, Codex accepted independent session IDs generated by Pi's `uuidv7()`. UUIDv4 values from `crypto.randomUUID()` and IDs modified with prefixes or suffixes were instead routed to an unavailable internal model.
37
+
38
+ ## Solution
39
+
40
+ `completeBackground()` applies special handling only to `openai-codex-responses`. Every call gets an independent Pi UUIDv7 session ID:
41
+
42
+ ```ts
43
+ completeSimple(model, context, { ...options, sessionId: uuidv7() });
44
+ ```
45
+
46
+ The background session resource is closed in `finally`. Non-Codex providers use an ordinary one-shot `completeSimple()` call without a shared session ID or transport override.
47
+
48
+ For OpenAI Codex, title, recap, and the main thread consequently have separate:
49
+
50
+ - WebSocket connections
51
+ - Response chains
52
+ - Session routing identities
53
+ - Prompt-cache identities
54
+
55
+ The main socket is never closed or modified by background work. Every background call uses a fresh ID, so overlapping title, recap, or retry calls cannot affect each other's response chains.
56
+
57
+ ## Error handling
58
+
59
+ Provider failures from `completeSimple()` may arrive as assistant messages rather than thrown exceptions. Title and recap therefore check:
60
+
61
+ ```ts
62
+ if (response.stopReason === "error") {
63
+ throw new Error(response.errorMessage ?? "Background generation failed");
64
+ }
65
+ ```
66
+
67
+ This prevents a provider error with empty content from being mistaken for an empty model response.
68
+
69
+ ## Cache and performance
70
+
71
+ The independent-session design preserves the main thread's WebSocket reuse, `previous_response_id` continuation, and prompt-cache identity. Background generation pays for its own WebSocket handshake, but that work does not invalidate or reconnect the main thread.
72
+
73
+ Cache admission remains provider-controlled and noisy. In repeated GPT-5.6 Luna title A/B probes, isolated background calls caused no continuation errors and showed no negative main-thread cache trend. Main second-turn latency remained around the normal control range instead of paying the reconnect penalty seen when the shared main socket was closed.
74
+
75
+ Use the [`measuring-model-cache`](../.agents/skills/measuring-model-cache/SKILL.md) skill to repeat the comparison after changes to Pi, providers, session handling, title, recap, or other background model features.
@@ -0,0 +1,255 @@
1
+ # OpenAI Codex Banked Rate-Limit Resets
2
+
3
+ > This document was generated with the [Pi](https://pi.dev/) coding agent.
4
+
5
+ OpenAI Codex allows eligible users to save a rate-limit reset and redeem it later. OpenAI calls this a **banked rate-limit reset**, usually shortened to **banked reset** or **reset**. Internal APIs and source code instead use **rate-limit reset credit** and `RateLimitResetCredit`.
6
+
7
+ A reset is not money, an OpenAI API credit, or a transferable benefit. User-facing interfaces should therefore say `reset`, while code may retain the API's `credit` terminology.
8
+
9
+ ## How resets work
10
+
11
+ A banked reset restores eligible Codex subscription rate-limit windows when redeemed. It is separate from the normal five-hour and weekly windows shown in Codex usage data.
12
+
13
+ Resets are promotional benefits, not recurring subscription allowances. Confirmed grants have included the June 2026 launch grant and referral promotions. OpenAI may offer different rewards in the future, with eligibility determined dynamically by plan, region, workspace, account state, prior Codex use, and promotion-specific limits. The offer shown in the product is authoritative.
14
+
15
+ Banked resets usually expire 30 days after they are granted unless an offer states otherwise. The API's `available_count` is the number already granted and currently available; it does not represent how many more resets a user may earn.
16
+
17
+ ## Integration surfaces
18
+
19
+ The feature has two integration surfaces:
20
+
21
+ - **Codex App Server JSON-RPC** is the documented, supported integration boundary
22
+ - **ChatGPT WHAM REST** is used by the official open-source Codex client and covered by its contract tests, but it is not a public REST API with a compatibility guarantee
23
+
24
+ The REST interface is convenient for a Pi extension because Pi already stores and refreshes the required ChatGPT OAuth credentials. It should nevertheless be isolated behind a small adapter so endpoint or schema changes do not spread through the feature.
25
+
26
+ ## WHAM REST endpoints
27
+
28
+ The confirmed production base URL is:
29
+
30
+ ```text
31
+ https://chatgpt.com/backend-api
32
+ ```
33
+
34
+ | Method | Path | Purpose |
35
+ | --- | --- | --- |
36
+ | `GET` | `/wham/usage` | Read rate-limit windows and the available-reset summary |
37
+ | `GET` | `/wham/rate-limit-reset-credits` | Read individual reset details |
38
+ | `POST` | `/wham/rate-limit-reset-credits/consume` | Redeem one reset |
39
+
40
+ The official client also supports paths under `<codex-backend-base>/api/codex/...`, but OpenAI has not documented a public production host for that form. In particular, do not assume that `https://api.openai.com/api/codex/...` is valid.
41
+
42
+ ## Authentication
43
+
44
+ These endpoints require ChatGPT subscription OAuth, not a normal OpenAI API key:
45
+
46
+ ```http
47
+ Authorization: Bearer <access_token>
48
+ ChatGPT-Account-ID: <account_id>
49
+ ```
50
+
51
+ A Pi extension should ask Pi's model registry to resolve the credential so expired OAuth tokens are refreshed. It should not read or parse `~/.pi/agent/auth.json` directly. Tokens and complete request headers must never be logged or included in model context.
52
+
53
+ ## Reading usage and the reset summary
54
+
55
+ Request:
56
+
57
+ ```http
58
+ GET /backend-api/wham/usage HTTP/1.1
59
+ Host: chatgpt.com
60
+ Authorization: Bearer <access_token>
61
+ ChatGPT-Account-ID: <account_id>
62
+ Accept: application/json
63
+ ```
64
+
65
+ Relevant response fields:
66
+
67
+ ```json
68
+ {
69
+ "plan_type": "plus",
70
+ "rate_limit": {
71
+ "primary_window": {
72
+ "used_percent": 27,
73
+ "limit_window_seconds": 18000,
74
+ "reset_after_seconds": 13140,
75
+ "reset_at": 1782770922
76
+ },
77
+ "secondary_window": {
78
+ "used_percent": 4,
79
+ "limit_window_seconds": 604800,
80
+ "reset_after_seconds": 566340,
81
+ "reset_at": 1783357722
82
+ }
83
+ },
84
+ "rate_limit_reset_credits": {
85
+ "available_count": 2
86
+ }
87
+ }
88
+ ```
89
+
90
+ `primary_window` is normally the five-hour window and `secondary_window` the weekly window. `reset_at` is a Unix timestamp; `reset_after_seconds` is the corresponding relative duration observed in live responses. Read the reset count defensively:
91
+
92
+ ```ts
93
+ const available = response.rate_limit_reset_credits?.available_count ?? 0;
94
+ ```
95
+
96
+ This endpoint provides only a summary. Use the details endpoint to show expiration times or select a particular reset.
97
+
98
+ ## Reading reset details
99
+
100
+ Request:
101
+
102
+ ```http
103
+ GET /backend-api/wham/rate-limit-reset-credits HTTP/1.1
104
+ Host: chatgpt.com
105
+ Authorization: Bearer <access_token>
106
+ ChatGPT-Account-ID: <account_id>
107
+ Accept: application/json
108
+ ```
109
+
110
+ Response:
111
+
112
+ ```json
113
+ {
114
+ "credits": [
115
+ {
116
+ "id": "RateLimitResetCredit_...",
117
+ "reset_type": "codex_rate_limits",
118
+ "status": "available",
119
+ "granted_at": "2026-06-17T00:00:00Z",
120
+ "expires_at": "2026-07-17T00:00:00Z",
121
+ "title": "Full reset (Weekly + 5 hr)",
122
+ "description": "Ready to redeem"
123
+ }
124
+ ],
125
+ "available_count": 2
126
+ }
127
+ ```
128
+
129
+ The official Codex client consumes these fields:
130
+
131
+ - `id`
132
+ - `reset_type`
133
+ - `status`
134
+ - `granted_at`
135
+ - `expires_at`
136
+ - `title`
137
+ - `description`
138
+ - `available_count`
139
+
140
+ The backend may return additional fields, but the official client deliberately ignores them. A live response contained `total_earned_count: 0` alongside four available credits, so `total_earned_count` must not be used as an earned or available balance. Nullable fields such as `redeem_started_at` and `redeemed_at` were also present but are unnecessary when listing available resets.
141
+
142
+ Treat `available_count` as authoritative. The backend may limit the number of entries in `credits`, so `credits.length` is not a reliable substitute. Only entries whose status is `available` should be offered for redemption.
143
+
144
+ ## Consuming a reset
145
+
146
+ Redeeming a reset is irreversible. The UI should show the selected reset, its expiration time, and the current rate-limit windows, then require explicit confirmation with a safe default of cancel.
147
+
148
+ Request:
149
+
150
+ ```http
151
+ POST /backend-api/wham/rate-limit-reset-credits/consume HTTP/1.1
152
+ Host: chatgpt.com
153
+ Authorization: Bearer <access_token>
154
+ ChatGPT-Account-ID: <account_id>
155
+ Accept: application/json
156
+ Content-Type: application/json
157
+
158
+ {
159
+ "redeem_request_id": "55cbe0c1-71ab-4c52-b855-80746f73ff52",
160
+ "credit_id": "RateLimitResetCredit_..."
161
+ }
162
+ ```
163
+
164
+ - `redeem_request_id` is a required idempotency key; a UUID is appropriate (UUIDv7 is suitable and is already available from `@earendil-works/pi-agent-core`)
165
+ - `credit_id` is optional; when omitted, the server chooses a reset
166
+ - Retries of the same logical redemption must reuse the original `redeem_request_id`
167
+
168
+ Successful response:
169
+
170
+ ```json
171
+ {
172
+ "code": "reset",
173
+ "credit": {
174
+ "id": "RateLimitResetCredit_...",
175
+ "reset_type": "codex_rate_limits",
176
+ "status": "redeemed",
177
+ "redeemed_at": "2026-07-10T12:30:00Z"
178
+ },
179
+ "windows_reset": 2
180
+ }
181
+ ```
182
+
183
+ The REST response uses one of four snake-case result codes:
184
+
185
+ | Code | Meaning |
186
+ | --- | --- |
187
+ | `reset` | The reset was redeemed and eligible windows were reset |
188
+ | `nothing_to_reset` | No rate-limit window was currently eligible |
189
+ | `no_credit` | No banked reset was available |
190
+ | `already_redeemed` | This idempotent request had already completed; treat it as success |
191
+
192
+ After `reset` or `already_redeemed`, refresh usage rather than deriving the new count or window state from stale client data. Reset details can be fetched again the next time the selection panel opens.
193
+
194
+ ## Error handling and compatibility
195
+
196
+ The REST API can change independently of pi-spark. An adapter should handle:
197
+
198
+ - Missing or newly added fields
199
+ - `401` for missing or expired authorization
200
+ - `403` for an ineligible account, workspace, region, or endpoint
201
+ - Non-JSON responses, including proxy or Cloudflare HTML
202
+ - Request cancellation and timeouts
203
+ - Ambiguous network failures during redemption
204
+
205
+ Do not include response bodies in user-facing errors unless they have been validated and sanitized. If the details endpoint fails, `/wham/usage` can still provide `available_count` for a degraded summary.
206
+
207
+ ## pi-spark behavior
208
+
209
+ When `/wham/usage` reports one or more available resets, the credits status appends a summary after the normal windows:
210
+
211
+ ```text
212
+ Codex 5h 42% 7d 7% (4 resets available)
213
+ ```
214
+
215
+ The suffix is omitted when no reset is available. `/codex-reset` opens an interactive panel that loads current usage and reset details, lists available resets by title, grant time, and expiration time, and sorts the earliest expiration first. Selecting a reset opens a separate confirmation prompt before any POST request is made.
216
+
217
+ Each confirmed redemption uses a new UUIDv7 idempotency key and makes one consume request without automatic retries. Successful and idempotently completed redemptions refresh the credits status.
218
+
219
+ ## Public App Server equivalent
220
+
221
+ The documented Codex App Server exposes equivalent operations over JSON-RPC.
222
+
223
+ Read limits and resets:
224
+
225
+ ```json
226
+ { "method": "account/rateLimits/read", "id": 1 }
227
+ ```
228
+
229
+ The response includes camel-case `rateLimitResetCredits`, `availableCount`, and individual credits.
230
+
231
+ Consume a reset:
232
+
233
+ ```json
234
+ {
235
+ "method": "account/rateLimitResetCredit/consume",
236
+ "id": 2,
237
+ "params": {
238
+ "idempotencyKey": "55cbe0c1-71ab-4c52-b855-80746f73ff52",
239
+ "creditId": "RateLimitResetCredit_..."
240
+ }
241
+ }
242
+ ```
243
+
244
+ The App Server is the safer boundary for long-lived external integrations. Direct WHAM access is simpler inside pi-spark today, but should remain replaceable.
245
+
246
+ ## References
247
+
248
+ - [Codex App Server](https://developers.openai.com/codex/app-server)
249
+ - [Using Codex with your ChatGPT plan](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan)
250
+ - [Codex Referral Promotions](https://help.openai.com/en/articles/20001271-codex-referral-promotions)
251
+ - [Codex pricing and invitations](https://developers.openai.com/codex/pricing)
252
+ - [Official REST implementation](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client/rate_limit_resets.rs)
253
+ - [Official REST contract tests](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs)
254
+ - [Official backend response types](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/types.rs)
255
+ - [App Server reset processing](https://github.com/openai/codex/blob/main/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs)
@@ -0,0 +1,99 @@
1
+ # Reverse-Engineering Fireworks Credits
2
+
3
+ > This document was generated with the [Pi](https://pi.dev/) coding agent.
4
+
5
+ Most providers in the credits feature expose a documented HTTP endpoint that returns a balance. [Fireworks](https://fireworks.ai/) does not. Its dashboard shows account credit, but there is no public REST route for it. This note records how the balance API was found by reverse-engineering the official `firectl` CLI, and how the provider is implemented on top of it.
6
+
7
+ ## Where the binary came from
8
+
9
+ Fireworks ships a command-line tool, [`firectl`](https://docs.fireworks.ai/tools-sdks/firectl/). The [Homebrew tap formula](https://github.com/fw-ai/homebrew-firectl/blob/main/Formula/firectl.rb) points at per-platform binaries; the `darwin-amd64` build was used here.
10
+
11
+ It is a Go binary, and crucially an **unstripped** one, which keeps the symbol table, the `pclntab` (function/line metadata), and the full module dependency list. None of that yields original source, but it exposes the program's structure in detail.
12
+
13
+ ```sh
14
+ go version -m firectl # build info: firectl 1.7.22, Go 1.25.0, module path
15
+ go tool nm firectl # 81k symbols, including package-qualified names
16
+ go tool objdump -s SYM firectl # disassembly annotated with file:line
17
+ go run github.com/goretk/redress@latest source firectl # folder/file/function tree
18
+ ```
19
+
20
+ ## Locating the credit feature
21
+
22
+ The CLI is a Cobra app that is a thin client over a gRPC service named `gateway.Gateway`. Listing the RPC method paths embedded in the binary surfaced the relevant calls:
23
+
24
+ ```sh
25
+ strings firectl | grep -oE '/gateway\.Gateway/[A-Za-z]+' | sort -u
26
+ # ... /gateway.Gateway/GetBalance
27
+ # ... /gateway.Gateway/GetLedger
28
+ # ... /gateway.Gateway/ListCreditRedemptions
29
+ # ... /gateway.Gateway/ListAccounts
30
+ ```
31
+
32
+ `GetBalance` is exactly what the dashboard shows, but checking the symbol table revealed that **no CLI command is wired to it** — it exists in the gateway client yet is not reachable through any `firectl` subcommand. So the goal became calling it directly.
33
+
34
+ ## Working out authentication
35
+
36
+ Two facts had to be recovered: the host and the credential format.
37
+
38
+ The binary contains several Fireworks hostnames. The inference API (`api.fireworks.ai`) is the well-known one, but the control plane uses a separate host:
39
+
40
+ ```sh
41
+ strings firectl | grep -oE '[a-z0-9.-]+\.fireworks\.ai' | sort -u
42
+ # api.fireworks.ai (inference)
43
+ # gateway.fireworks.ai (control-plane gRPC) <- this one
44
+ # app.fireworks.ai, device-auth.fireworks.ai
45
+ ```
46
+
47
+ For the credential, the disassembly of the client's auth interceptor showed it sets gRPC metadata and references both `authorization` (Bearer) and `x-api-key`. The Bearer slot expects an OIDC ID token, which is what interactive `firectl signin` produces. API-key auth uses the `x-api-key` header instead.
48
+
49
+ This matched what happened on the wire while probing the endpoint with a saved API key:
50
+
51
+ - `api.fireworks.ai:443` returned HTTP 404 — wrong host for this service.
52
+ - `gateway.fireworks.ai:443` with `authorization: Bearer <key>` returned `UNAUTHENTICATED: invalid id token` — the key is not a JWT.
53
+ - `gateway.fireworks.ai:443` with `x-api-key: <key>` authenticated, then returned `INVALID_ARGUMENT` — auth worked; the request body was incomplete.
54
+
55
+ ## Reconstructing the messages
56
+
57
+ The Go protobuf structs carry their field tags as string literals, so the message shapes can be read straight out of the binary:
58
+
59
+ ```sh
60
+ strings firectl | grep -oE 'protobuf:"[^"]*name=(name|money|currency_code|units|nanos)[^"]*"'
61
+ ```
62
+
63
+ That, plus the generated getter names (`(*Balance).GetMoney`, `(*GetBalanceRequest).GetName`), gives:
64
+
65
+ ```proto
66
+ message GetBalanceRequest { string name = 1; } // "accounts/<account_id>"
67
+ message Balance { Money money = 1; }
68
+ message Money { string currency_code = 1; int64 units = 2; int32 nanos = 3; } // google.type.Money
69
+ ```
70
+
71
+ `GetBalance` needs a `name` like `accounts/<account_id>`. Rather than ask the user for their account id, `ListAccounts` (which the same API key can call) returns it:
72
+
73
+ ```proto
74
+ message Account { string name = 1; string display_name = 2; }
75
+ message ListAccountsResponse { repeated Account accounts = 1; }
76
+ ```
77
+
78
+ ## Confirming end to end
79
+
80
+ A minimal Node.js client using `@grpc/grpc-js` and `@grpc/proto-loader` confirmed the full flow against the live service:
81
+
82
+ 1. `ListAccounts` with `x-api-key` → `accounts/<id>`.
83
+ 2. `GetBalance { name }` → `Balance { money: { currency_code, units, nanos } }`.
84
+
85
+ The amount is `units + nanos / 1e9` (the standard `google.type.Money` encoding), e.g., `units=9, nanos=694293840` → `$9.69`.
86
+
87
+ ## How the provider is implemented in the credits feature
88
+
89
+ The provider lives in [`src/features/credits/providers/fireworks.ts`](../src/features/credits/providers/fireworks.ts) and follows the same `CreditsProvider` contract as the HTTP-based providers; only the transport differs.
90
+
91
+ - **Transport.** A single, reused `@grpc/grpc-js` client dials `gateway.fireworks.ai:443` over TLS. The service is declared in [`fireworks.proto`](../src/features/credits/providers/fireworks.proto) — the minimal slice needed, loaded at runtime with `@grpc/proto-loader`. The `.proto` is resolved relative to the module via `import.meta.url`, so it ships and loads from the package directory.
92
+ - **Auth.** Each call sends `x-api-key: <key>` in gRPC metadata. The key comes from Pi's `modelRegistry.getApiKeyForProvider("fireworks")` (env `FIREWORKS_API_KEY`), handled by the manager.
93
+ - **Account resolution.** `ListAccounts` is called once and the resolved `accounts/<id>` is cached per API key, so steady-state refreshes are a single `GetBalance` round trip.
94
+ - **Cancellation.** The shared `AbortSignal` cancels the in-flight gRPC call, and a defensive deadline bounds each request.
95
+ - **Result.** `Money` is converted to a dollar amount and returned as `{ type: "balance", remaining }`, which the status line renders like any other balance provider.
96
+
97
+ ## Caveats
98
+
99
+ `GetBalance` is an internal API: it is intentionally absent from `firectl`, undocumented, and not part of any public contract. Its host, request shape, and behavior can change at any time without notice, which would break this provider. The narrow `.proto` keeps the blast radius small if that happens.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Pi package that polishes your daily experience.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -16,6 +16,7 @@
16
16
  "index.ts",
17
17
  "src",
18
18
  "assets",
19
+ "docs",
19
20
  "README.md",
20
21
  "LICENSE"
21
22
  ],
@@ -36,19 +37,19 @@
36
37
  "zod": "^4.4.3"
37
38
  },
38
39
  "peerDependencies": {
39
- "@earendil-works/pi-agent-core": "*",
40
- "@earendil-works/pi-ai": "*",
41
- "@earendil-works/pi-coding-agent": "*",
42
- "@earendil-works/pi-tui": "*",
40
+ "@earendil-works/pi-agent-core": ">=0.80.6",
41
+ "@earendil-works/pi-ai": ">=0.80.6",
42
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
43
+ "@earendil-works/pi-tui": ">=0.80.6",
43
44
  "typebox": "*"
44
45
  },
45
46
  "devDependencies": {
46
- "@earendil-works/pi-agent-core": "*",
47
- "@earendil-works/pi-ai": "*",
48
- "@earendil-works/pi-coding-agent": "*",
49
- "@earendil-works/pi-tui": "*",
47
+ "@earendil-works/pi-agent-core": ">=0.80.6",
48
+ "@earendil-works/pi-ai": ">=0.80.6",
49
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
50
+ "@earendil-works/pi-tui": ">=0.80.6",
50
51
  "@types/node": "*",
51
52
  "typebox": "*",
52
- "typescript": "^6.0.3"
53
+ "typescript": "^7.0.2"
53
54
  }
54
55
  }
@@ -0,0 +1,17 @@
1
+ import { Loader as BaseLoader } from "@earendil-works/pi-tui";
2
+
3
+ /** The built-in loader hardcodes 1 column of horizontal padding, which is private. */
4
+ const PADDING_X = 1;
5
+
6
+ export class Loader extends BaseLoader {
7
+ override render(width: number): string[] {
8
+ // Inflate the width so the base loader keeps full content width, then strip its padding.
9
+ return super.render(width + PADDING_X * 2).map((line) => {
10
+ if (line.length === 0) {
11
+ return line;
12
+ }
13
+
14
+ return line.slice(PADDING_X, line.length - PADDING_X);
15
+ });
16
+ }
17
+ }
@@ -1,4 +1,5 @@
1
1
  import { CreditsManager } from "./manager";
2
+ import { registerProviderExtensions } from "./providers";
2
3
  import { loadConfig } from "../../config";
3
4
  import { isUsage } from "../../utils/usage";
4
5
 
@@ -20,6 +21,8 @@ export function registerCredits(pi: ExtensionAPI): void {
20
21
  if (!ctx.hasUI || !config) return;
21
22
 
22
23
  creditsManager = new CreditsManager();
24
+ registerProviderExtensions(pi, ctx, async (ctx) => await creditsManager?.refresh(ctx));
25
+
23
26
  creditsManager.refresh(ctx);
24
27
  });
25
28
 
@@ -6,7 +6,8 @@ import { openaiCodexProvider } from "./openai-codex";
6
6
  import { openrouterProvider } from "./openrouter";
7
7
  import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
8
8
 
9
- import type { CreditsProvider } from "../types";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { CreditsProvider, RefreshCredits } from "../types";
10
11
 
11
12
  const PROVIDERS: CreditsProvider[] = [
12
13
  deepseekProvider,
@@ -22,3 +23,7 @@ const PROVIDERS: CreditsProvider[] = [
22
23
  export function findProvider(provider?: string): CreditsProvider | undefined {
23
24
  return PROVIDERS.find((entry) => entry.id === provider);
24
25
  }
26
+
27
+ export function registerProviderExtensions(pi: ExtensionAPI, ctx: ExtensionContext, refresh: RefreshCredits): void {
28
+ for (const provider of PROVIDERS) provider.register?.(pi, ctx, refresh);
29
+ }
@@ -0,0 +1,229 @@
1
+ import { DynamicBorder, keyHint, rawKeyHint } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, matchesKey, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
3
+
4
+ import { Loader } from "../../../components/loader";
5
+ import { renderCredits } from "../status";
6
+
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import type { SelectItem } from "@earendil-works/pi-tui";
9
+ import type { BankedRateLimitReset, RateLimitResetCreditsResponse } from "./openai-codex";
10
+ import type { Credits } from "../types";
11
+
12
+ const DATE_COLUMN_WIDTH = 14;
13
+
14
+ export interface CodexResetPanelData extends RateLimitResetCreditsResponse {
15
+ usage: Credits;
16
+ }
17
+
18
+ export async function showCodexResetSelector(ctx: ExtensionContext, load: (signal: AbortSignal) => Promise<CodexResetPanelData>): Promise<BankedRateLimitReset | undefined> {
19
+ const selected = await ctx.ui.custom<BankedRateLimitReset | null>((tui, theme, _keybindings, done) => {
20
+ const controller = new AbortController();
21
+
22
+ const container = new Container();
23
+ const box = new Box(1, 1);
24
+ const loader = new Loader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), "Loading...");
25
+
26
+ let settled = false;
27
+ let selectList: SelectList | undefined;
28
+
29
+ const finish = (value: BankedRateLimitReset | null) => {
30
+ if (settled) return;
31
+
32
+ settled = true;
33
+ controller.abort();
34
+ loader.stop();
35
+
36
+ done(value);
37
+ };
38
+
39
+ container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
40
+
41
+ box.addChild(new Text(theme.bold(theme.fg("accent", "OpenAI Codex banked rate-limit resets")), 0, 0));
42
+ box.addChild(loader);
43
+ box.addChild(new Spacer(1));
44
+ box.addChild(new Text(keyHint("tui.select.cancel", "cancel"), 0, 0));
45
+
46
+ container.addChild(box);
47
+ container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
48
+
49
+ loader.start();
50
+
51
+ load(controller.signal).then((data) => {
52
+ if (settled) return;
53
+
54
+ loader.stop();
55
+
56
+ const credits = data.credits.filter((credit) => credit.status === "available").toSorted((a, b) => getExpirationTime(a) - getExpirationTime(b));
57
+ if (data.available_count === 0) {
58
+ ctx.ui.notify("No Codex resets available", "info");
59
+ finish(null);
60
+ return;
61
+ }
62
+ if (credits.length === 0) {
63
+ ctx.ui.notify(`${formatAvailableResets(data.available_count)}, but no reset details were returned`, "warning");
64
+ finish(null);
65
+ return;
66
+ }
67
+
68
+ box.clear();
69
+ box.addChild(new Text(theme.bold(theme.fg("accent", "OpenAI Codex banked rate-limit resets")), 0, 0));
70
+ box.addChild(new Text(renderCredits(theme, "", data.usage), 0, 0));
71
+ box.addChild(new Spacer(1));
72
+
73
+ const creditsById = new Map(credits.map((credit) => [credit.id, credit]));
74
+ const items: SelectItem[] = credits.map((credit) => ({
75
+ value: credit.id,
76
+ label: formatCreditTitle(credit),
77
+ description: formatCreditDescription(credit),
78
+ }));
79
+
80
+ selectList = new SelectList(items, Math.min(items.length, 10), {
81
+ selectedPrefix: (text) => theme.fg("accent", text),
82
+ selectedText: (text) => theme.fg("accent", text),
83
+ description: (text) => theme.fg("muted", text),
84
+ scrollInfo: (text) => theme.fg("dim", text),
85
+ noMatch: (text) => theme.fg("warning", text),
86
+ });
87
+ selectList.onSelect = (item) => finish(creditsById.get(item.value) ?? null);
88
+ selectList.onCancel = () => finish(null);
89
+
90
+ box.addChild(selectList);
91
+ box.addChild(new Spacer(1));
92
+
93
+ const keyHints = [rawKeyHint("↑↓", "navigate"), keyHint("tui.select.confirm", "redeem"), keyHint("tui.select.cancel", "cancel")];
94
+ box.addChild(new Text(keyHints.join(" "), 0, 0));
95
+
96
+ tui.requestRender();
97
+ }).catch((error) => {
98
+ if (settled || controller.signal.aborted) return;
99
+
100
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
101
+ finish(null);
102
+ });
103
+
104
+ return {
105
+ render: (width: number) => container.render(width),
106
+ invalidate: () => container.invalidate(),
107
+ handleInput: (data: string) => {
108
+ if (selectList) {
109
+ selectList.handleInput(data);
110
+ tui.requestRender();
111
+ } else if (matchesKey(data, "escape")) {
112
+ finish(null);
113
+ }
114
+ },
115
+ };
116
+ });
117
+
118
+ return selected ?? undefined;
119
+ }
120
+
121
+ export async function confirmCodexReset(ctx: ExtensionContext, credit: BankedRateLimitReset): Promise<boolean> {
122
+ const confirmed = await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
123
+ const container = new Container();
124
+ container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
125
+
126
+ const box = new Box(1, 1);
127
+ box.addChild(new Text(theme.bold(theme.fg("accent", "Redeem reset?")), 0, 0));
128
+ box.addChild(new Spacer(1));
129
+
130
+ const details = [
131
+ formatCreditTitle(credit),
132
+ formatCreditDescription(credit, false),
133
+ credit.description?.trim(),
134
+ ].filter(Boolean).join("\n");
135
+
136
+ box.addChild(new Text(theme.fg("muted", details), 0, 0));
137
+ box.addChild(new Spacer(1));
138
+ box.addChild(new Text(theme.fg("warning", "This permanently consumes one banked reset and resets all eligible windows."), 0, 0));
139
+ box.addChild(new Spacer(1));
140
+
141
+ const items: SelectItem[] = [
142
+ { value: "yes", label: "Yes" },
143
+ { value: "no", label: "No" },
144
+ ];
145
+
146
+ const selectList = new SelectList(items, 2, {
147
+ selectedPrefix: (text) => theme.fg("accent", text),
148
+ selectedText: (text) => theme.fg("accent", text),
149
+ description: (text) => theme.fg("muted", text),
150
+ scrollInfo: (text) => theme.fg("dim", text),
151
+ noMatch: (text) => theme.fg("warning", text),
152
+ });
153
+ selectList.onSelect = (item) => done(item.value === "yes");
154
+ selectList.onCancel = () => done(false);
155
+
156
+ box.addChild(selectList);
157
+ box.addChild(new Spacer(1));
158
+
159
+ const keyHints = [rawKeyHint("↑↓", "navigate"), keyHint("tui.select.confirm", "confirm"), keyHint("tui.select.cancel", "cancel")];
160
+ box.addChild(new Text(keyHints.join(" "), 0, 0));
161
+
162
+ container.addChild(box);
163
+ container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
164
+
165
+ return {
166
+ render: (width: number) => container.render(width),
167
+ invalidate: () => container.invalidate(),
168
+ handleInput: (data: string) => {
169
+ selectList.handleInput(data);
170
+ tui.requestRender();
171
+ },
172
+ };
173
+ });
174
+
175
+ return confirmed ?? false;
176
+ }
177
+
178
+ export async function showCodexResetLoader(ctx: ExtensionContext, run: () => Promise<void>): Promise<void> {
179
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
180
+ const container = new Container();
181
+ const loader = new Loader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), "Redeeming...");
182
+
183
+ container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
184
+
185
+ const box = new Box(1, 1);
186
+ box.addChild(new Text(theme.bold(theme.fg("accent", "OpenAI Codex banked rate-limit resets")), 0, 0));
187
+ box.addChild(loader);
188
+
189
+ container.addChild(box);
190
+ container.addChild(new DynamicBorder((text: string) => theme.fg("border", text)));
191
+
192
+ loader.start();
193
+
194
+ run()
195
+ .catch((error: unknown) => ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"))
196
+ .finally(() => {
197
+ loader.stop();
198
+ done();
199
+ });
200
+
201
+ return {
202
+ render: (width: number) => container.render(width),
203
+ invalidate: () => container.invalidate(),
204
+ };
205
+ });
206
+ }
207
+
208
+ export function formatAvailableResets(count: number): string {
209
+ return `${count} ${count === 1 ? "reset" : "resets"} available`;
210
+ }
211
+
212
+ function formatCreditTitle(credit: BankedRateLimitReset): string {
213
+ return credit.title?.trim() || "Banked rate-limit reset";
214
+ }
215
+
216
+ function formatCreditDescription(credit: BankedRateLimitReset, padEnd: boolean = true): string {
217
+ const formatDate = (value: string) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(new Date(value));
218
+
219
+ const granted = padEnd ? `Granted ${formatDate(credit.granted_at)}`.padEnd(DATE_COLUMN_WIDTH) : `Granted ${formatDate(credit.granted_at)}`;
220
+ const expires = padEnd ? (credit.expires_at ? `Expires ${formatDate(credit.expires_at)}` : "No expiration").padEnd(DATE_COLUMN_WIDTH) : (credit.expires_at ? `Expires ${formatDate(credit.expires_at)}` : "No expiration");
221
+ return `${granted} · ${expires}`;
222
+ }
223
+
224
+ function getExpirationTime(credit: BankedRateLimitReset): number {
225
+ if (!credit.expires_at) return Number.POSITIVE_INFINITY;
226
+
227
+ const timestamp = Date.parse(credit.expires_at);
228
+ return Number.isNaN(timestamp) ? Number.POSITIVE_INFINITY : timestamp;
229
+ }
@@ -1,16 +1,25 @@
1
+ import { uuidv7 } from "@earendil-works/pi-agent-core";
2
+
3
+ import { confirmCodexReset, formatAvailableResets, showCodexResetLoader, showCodexResetSelector } from "./openai-codex-panel";
1
4
  import { toNumber } from "../../../utils/format";
2
5
 
3
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import type { Credits, CreditsProvider } from "../types";
7
+ import type { CodexResetPanelData } from "./openai-codex-panel";
8
+ import type { Credits, CreditsProvider, RefreshCredits } from "../types";
5
9
 
6
10
  const PROVIDER = "openai-codex";
7
- const URL = "https://chatgpt.com/backend-api/wham/usage";
11
+ const BASE_URL = "https://chatgpt.com/backend-api";
12
+ const USAGE_PATH = "/wham/usage";
13
+ const RESET_CREDITS_PATH = "/wham/rate-limit-reset-credits";
14
+ const CONSUME_RESET_PATH = "/wham/rate-limit-reset-credits/consume";
15
+ const REQUEST_TIMEOUT_MS = 30_000;
8
16
 
9
17
  interface CodexUsageResponse {
10
18
  rate_limit?: {
11
19
  primary_window?: CodexRateWindow | null;
12
20
  secondary_window?: CodexRateWindow | null;
13
21
  } | null;
22
+ rate_limit_reset_credits?: { available_count: number } | null;
14
23
  credits?: { unlimited?: boolean } | null;
15
24
  }
16
25
 
@@ -18,16 +27,116 @@ interface CodexRateWindow {
18
27
  used_percent?: number | string;
19
28
  }
20
29
 
21
- function getAccountId(ctx: ExtensionContext): string | undefined {
22
- const credential = ctx.modelRegistry.authStorage.get(PROVIDER) as { accountId?: string } | undefined;
23
- const accountId = credential?.accountId;
30
+ export interface BankedRateLimitReset {
31
+ id: string;
32
+ status: string;
33
+ granted_at: string;
34
+ expires_at: string | null;
35
+ title?: string | null;
36
+ description?: string | null;
37
+ }
38
+
39
+ export interface RateLimitResetCreditsResponse {
40
+ credits: BankedRateLimitReset[];
41
+ available_count: number;
42
+ }
43
+
44
+ interface ConsumeResetResponse {
45
+ code: "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
46
+ windows_reset: number;
47
+ }
48
+
49
+ async function runCodexReset(ctx: ExtensionContext, refresh: RefreshCredits): Promise<void> {
50
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
51
+ if (!apiKey) return;
52
+
53
+ const headers = buildHeaders(ctx, apiKey);
54
+
55
+ const credit = await showCodexResetSelector(ctx, async (signal) => {
56
+ const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]);
57
+ const [usage, details] = await Promise.all([fetchUsage(headers, requestSignal), fetchResetCredits(headers, requestSignal)]);
58
+
59
+ return {
60
+ ...details,
61
+ usage: toCredits(usage, `· ${formatAvailableResets(details.available_count)}`),
62
+ } satisfies CodexResetPanelData;
63
+ });
64
+ if (!credit || !(await confirmCodexReset(ctx, credit))) return;
65
+
66
+ await redeemReset(ctx, headers, credit, refresh);
67
+ }
68
+
69
+ async function redeemReset(ctx: ExtensionContext, headers: Record<string, string>, credit: BankedRateLimitReset, refresh: RefreshCredits): Promise<void> {
70
+ await showCodexResetLoader(ctx, async () => {
71
+ const response = await consumeReset(headers, uuidv7(), credit.id);
72
+ if (response.code === "nothing_to_reset") throw new Error("No Codex rate-limit window currently needs a reset");
73
+ if (response.code === "no_credit") throw new Error("No Codex resets are available");
74
+ }).finally(() => refresh(ctx));
75
+ }
76
+
77
+ function buildHeaders(ctx: ExtensionContext, apiKey: string): Record<string, string> {
78
+ const headers: Record<string, string> = {
79
+ Accept: "application/json",
80
+ Authorization: `Bearer ${apiKey}`,
81
+ };
24
82
 
25
- return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
83
+ const credential = ctx.modelRegistry.authStorage.get(PROVIDER);
84
+ const accountId = credential?.type === "oauth" ? (credential.accountId as string | undefined) : undefined;
85
+ if (accountId) headers["ChatGPT-Account-ID"] = accountId;
86
+
87
+ return headers;
88
+ }
89
+
90
+ async function requestJson<T>(path: string, headers: Record<string, string>, signal: AbortSignal, init: RequestInit = {}): Promise<T> {
91
+ const requestHeaders = new Headers(headers);
92
+ new Headers(init.headers).forEach((value, key) => requestHeaders.set(key, value));
93
+
94
+ const response = await fetch(`${BASE_URL}${path}`, {
95
+ ...init,
96
+ headers: requestHeaders,
97
+ signal,
98
+ });
99
+ if (!response.ok) throw new Error(`Codex backend returned ${response.status}`);
100
+
101
+ return (await response.json()) as T;
102
+ }
103
+
104
+ async function fetchUsage(headers: Record<string, string>, signal: AbortSignal): Promise<CodexUsageResponse> {
105
+ return requestJson<CodexUsageResponse>(USAGE_PATH, headers, signal);
106
+ }
107
+
108
+ async function fetchResetCredits(headers: Record<string, string>, signal: AbortSignal): Promise<RateLimitResetCreditsResponse> {
109
+ return requestJson<RateLimitResetCreditsResponse>(RESET_CREDITS_PATH, headers, signal);
110
+ }
111
+
112
+ async function consumeReset(headers: Record<string, string>, redeemRequestId: string, creditId: string): Promise<ConsumeResetResponse> {
113
+ return requestJson<ConsumeResetResponse>(
114
+ CONSUME_RESET_PATH,
115
+ headers,
116
+ AbortSignal.timeout(REQUEST_TIMEOUT_MS),
117
+ {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({ redeem_request_id: redeemRequestId, credit_id: creditId }),
121
+ },
122
+ );
123
+ }
124
+
125
+ function toCredits(usage: CodexUsageResponse, suffix?: string): Credits {
126
+ return {
127
+ type: "windows",
128
+ unlimited: usage.credits?.unlimited === true,
129
+ lanes: [
130
+ { label: "5h", percent: parseUsedPercent(usage.rate_limit?.primary_window) },
131
+ { label: "7d", percent: parseUsedPercent(usage.rate_limit?.secondary_window) },
132
+ ],
133
+ suffix,
134
+ };
26
135
  }
27
136
 
28
137
  function parseUsedPercent(window?: CodexRateWindow | null): number | undefined {
29
138
  const value = toNumber(window?.used_percent);
30
- return typeof value === "number" ? Math.min(100, Math.max(0, value)) : undefined;
139
+ return value === undefined ? undefined : Math.min(100, Math.max(0, value));
31
140
  }
32
141
 
33
142
  export const openaiCodexProvider: CreditsProvider = {
@@ -35,26 +144,21 @@ export const openaiCodexProvider: CreditsProvider = {
35
144
  label: "Codex",
36
145
 
37
146
  async fetch(ctx, apiKey, signal): Promise<Credits> {
38
- const headers: Record<string, string> = {
39
- Accept: "application/json",
40
- Authorization: `Bearer ${apiKey}`,
41
- };
42
-
43
- const accountId = getAccountId(ctx);
44
- if (accountId) headers["ChatGPT-Account-Id"] = accountId;
147
+ const headers = buildHeaders(ctx, apiKey);
148
+ const usage = await fetchUsage(headers, signal);
149
+ const availableCount = usage.rate_limit_reset_credits?.available_count ?? 0;
150
+ const suffix = availableCount > 0 ? `(${formatAvailableResets(availableCount)})` : undefined;
45
151
 
46
- const response = await fetch(URL, { headers, signal });
47
- if (!response.ok) throw new Error("request failed");
152
+ return toCredits(usage, suffix);
153
+ },
48
154
 
49
- const payload = (await response.json()) as CodexUsageResponse;
155
+ register(pi, ctx, refresh): void {
156
+ const credential = ctx.modelRegistry.authStorage.get(PROVIDER);
157
+ if (credential?.type !== "oauth") return;
50
158
 
51
- return {
52
- type: "windows",
53
- unlimited: payload.credits?.unlimited === true,
54
- lanes: [
55
- { label: "5h", percent: parseUsedPercent(payload.rate_limit?.primary_window) },
56
- { label: "7d", percent: parseUsedPercent(payload.rate_limit?.secondary_window) },
57
- ],
58
- };
159
+ pi.registerCommand("codex-reset", {
160
+ description: "Select and redeem a banked OpenAI Codex rate-limit reset",
161
+ handler: async (_args, ctx) => await runCodexReset(ctx, refresh),
162
+ });
59
163
  },
60
164
  };
@@ -7,10 +7,11 @@ const BALANCE_WARNING = 10;
7
7
  const BALANCE_ERROR = 5;
8
8
 
9
9
  export function renderCredits(theme: Theme, label: string, credits: Credits): string {
10
- const styledLabel = theme.fg("dim", label);
10
+ const styledLabel = theme.fg("dim", label ? `${label} ` : "");
11
+ const value = credits.type === "windows" ? renderWindows(theme, credits) : renderBalance(theme, credits);
12
+ const suffix = credits.suffix ? ` ${theme.fg("dim", credits.suffix)}` : "";
11
13
 
12
- if (credits.type === "windows") return `${styledLabel} ${renderWindows(theme, credits)}`;
13
- return `${styledLabel} ${renderBalance(theme, credits)}`;
14
+ return `${styledLabel}${value}${suffix}`;
14
15
  }
15
16
 
16
17
  export function renderError(theme: Theme, label: string, message: string): string {
@@ -1,5 +1,5 @@
1
1
  import type { ProviderId } from "@earendil-works/pi-ai";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  /**
5
5
  * Normalized credits/usage for a provider.
@@ -7,18 +7,22 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  * - `balance` providers (e.g., OpenRouter, Vercel AI Gateway) report a remaining dollar balance.
8
8
  * - `windows` providers (e.g., OpenAI Codex) report rate-limit windows as used percentages.
9
9
  */
10
- export type Credits =
10
+ export type Credits = (
11
11
  | { type: "balance"; remaining?: number | undefined }
12
- | { type: "windows"; lanes: CreditsLane[]; unlimited?: boolean | undefined };
12
+ | { type: "windows"; lanes: CreditsLane[]; unlimited?: boolean | undefined }
13
+ ) & { suffix?: string | undefined };
13
14
 
14
15
  export interface CreditsLane {
15
16
  label: string;
16
17
  percent: number | undefined;
17
18
  }
18
19
 
20
+ export type RefreshCredits = (ctx: ExtensionContext) => Promise<void>;
21
+
19
22
  /** A credits source for a Pi provider, shown in the status line while that provider is active. */
20
23
  export interface CreditsProvider {
21
24
  readonly id: ProviderId;
22
25
  readonly label: string;
23
26
  fetch(ctx: ExtensionContext, apiKey: string, signal: AbortSignal): Promise<Credits>;
27
+ register?(pi: ExtensionAPI, ctx: ExtensionContext, refresh: RefreshCredits): void;
24
28
  }
@@ -155,6 +155,11 @@ export function registerEditor(pi: ExtensionAPI, events: EventCollector): void {
155
155
  pi.on("agent_end", () => {
156
156
  runningToolCallIds.clear();
157
157
  editor?.setWorkingMessage();
158
+ });
159
+
160
+ pi.on("agent_settled", () => {
161
+ runningToolCallIds.clear();
162
+ editor?.setWorkingMessage();
158
163
  spinner?.stop();
159
164
  });
160
165
 
@@ -38,11 +38,7 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
38
38
  box.addChild(selectList);
39
39
  box.addChild(new Spacer(1));
40
40
 
41
- const keyHints = [
42
- rawKeyHint("↑↓", "navigate"),
43
- keyHint("tui.select.confirm", "select"),
44
- keyHint("tui.select.cancel", "cancel"),
45
- ];
41
+ const keyHints = [rawKeyHint("↑↓", "navigate"), keyHint("tui.select.confirm", "select"), keyHint("tui.select.cancel", "cancel")];
46
42
  box.addChild(new Text(keyHints.join(" "), 0, 0));
47
43
 
48
44
  container.addChild(box);
@@ -48,7 +48,7 @@ export function registerRecap(pi: ExtensionAPI): void {
48
48
  idleListener?.wake(ctx);
49
49
  });
50
50
 
51
- pi.on("agent_end", (_event, ctx) => {
51
+ pi.on("agent_settled", (_event, ctx) => {
52
52
  idleListener?.watch(ctx);
53
53
  });
54
54
 
@@ -15,8 +15,8 @@ export function registerTitle(pi: ExtensionAPI): void {
15
15
 
16
16
  // Generate the title after the first exchange, once the session has context to summarize.
17
17
  // The manager runs at most once and skips sessions that already have a name.
18
- pi.on("agent_end", (_event, ctx) => {
19
- titleManager?.run(ctx);
18
+ pi.on("agent_settled", (_event, ctx) => {
19
+ void titleManager?.run(ctx);
20
20
  });
21
21
 
22
22
  pi.on("session_shutdown", () => {
@@ -12,6 +12,7 @@ const SYSTEM_PROMPT = [
12
12
  "Base the title only on the transcript; do not invent topics, files, or intent.",
13
13
  "Capture the user's main goal or the session's central task.",
14
14
  "Respond in the conversation's primary language.",
15
+ "For English, use sentence case, never title case. Add spaces between English and CJK text.",
15
16
  "Output a single title of 3-8 words. No trailing punctuation, quotes, markdown, or prefix like 'Title:'.",
16
17
  ].join(" ");
17
18