pi-devin-auth 0.1.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/AGENTS.md ADDED
@@ -0,0 +1,63 @@
1
+ # AGENTS.md — pi-devin-auth
2
+
3
+ ## Overview
4
+
5
+ A [pi](https://pi.dev) coding agent extension that registers the `devin` provider (Cognition / Windsurf) with OAuth login and native streaming. Reuses the cloud-direct gRPC layer from `opencode-windsurf-auth`.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ extensions/index.ts # pi extension entry — registerProvider("devin", { oauth, streamSimple, models })
11
+ src/oauth/
12
+ login.ts # ADAPTED — pi OAuthLoginCallbacks (manual-paste flow)
13
+ register-user.ts # COPIED verbatim — RegisterUser RPC at register.windsurf.com
14
+ types.ts # COPIED verbatim — WindsurfRegion, DEFAULT_REGION
15
+ src/cloud-direct/ # COPIED verbatim — gRPC to server.codeium.com
16
+ auth.ts # GetUserJwt (short-lived JWT mint + in-memory cache)
17
+ chat.ts # GetChatMessage streaming (Connect-RPC + manual protobuf)
18
+ catalog.ts # GetCascadeModelConfigs (per-account model catalog)
19
+ metadata.ts # Metadata proto builder
20
+ wire.ts # Protobuf + Connect-streaming envelope helpers
21
+ index.ts # Public re-exports
22
+ src/context-map.ts # NEW — pi Message[]/Tool[] -> ChatHistoryItem[]/ToolDef[]
23
+ src/stream.ts # NEW — streamDevin: streamSimple impl (CloudChatEvent -> pi events)
24
+ src/models.ts # NEW — dynamic catalog -> ProviderModelConfig[] + fallback
25
+ ```
26
+
27
+ ## Build & Test
28
+
29
+ ```bash
30
+ npm install # install deps
31
+ npx tsc --noEmit # typecheck
32
+ npm test # run unit tests (bun test or node --test)
33
+ ```
34
+
35
+ pi loads extensions via jiti (no build step needed for runtime use).
36
+
37
+ ## Key Design Decisions
38
+
39
+ 1. **Native streamSimple** (Option B): No background proxy. `streamDevin()` calls `streamChatEvents()` directly and emits pi's `AssistantMessageEventStream` events.
40
+ 2. **Manual-paste OAuth**: pi's `OAuthLoginCallbacks` doesn't support loopback servers. We use `redirect_uri=show-auth-token` so the Windsurf SPA renders the token for the user to paste via `callbacks.onPrompt()`.
41
+ 3. **Non-expiring token shape**: Windsurf's `RegisterUser` returns a long-lived `api_key` with no refresh token. We set `OAuthCredentials = { refresh: "", access: apiKey, expires: now + 365 days }`. `refreshToken()` is a no-op.
42
+ 4. **Dynamic model catalog**: Fetched from `GetCascadeModelConfigs` via `oauth.modifyModels()` after login. Static fallback for offline.
43
+ 5. **In-memory JWT cache only**: No disk persistence for the short-lived `user_jwt`. Re-mint cost (~200ms) is negligible.
44
+
45
+ ## Token Shapes
46
+
47
+ - **firebaseIdToken**: Short-lived JWT from Auth0 browser sign-in. Exchanged via RegisterUser, then discarded.
48
+ - **api_key**: Long-lived credential from RegisterUser. Format: `devin-session-token$<JWT>`. Used as `Metadata.api_key` in every gRPC call. Stored by pi in `~/.pi/agent/auth.json`.
49
+ - **user_jwt**: Short-lived (~24 min) JWT minted from `GetUserJwt`. Cached in-memory per (apiKey, host). Required alongside api_key for chat RPCs.
50
+
51
+ ## Cloud-Direct gRPC
52
+
53
+ All RPCs hit `https://server.codeium.com` over HTTPS with Connect-RPC framing:
54
+ - `GetUserJwt` — unary, `application/proto`
55
+ - `GetCascadeModelConfigs` — unary, `application/proto`
56
+ - `GetChatMessage` — streaming, `application/connect+proto` (gzip-compressed frames)
57
+
58
+ Manual protobuf encoding (no protobuf library). Field numbers hardcoded from mitm captures of Windsurf's language_server traffic.
59
+
60
+ ## Related
61
+
62
+ - [opencode-windsurf-auth](https://github.com/rsvedant/opencode-windsurf-auth) — the opencode plugin this is derived from
63
+ - [pi custom-provider docs](https://pi.dev/docs/latest/custom-provider)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nmzpy
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 ADDED
@@ -0,0 +1,64 @@
1
+ # pi-devin-auth
2
+
3
+ A [pi](https://pi.dev) coding agent extension that adds the **Devin** (Cognition / Windsurf) provider with browser-based OAuth login and native streaming.
4
+
5
+ ## Install
6
+
7
+ ### Via pi (recommended)
8
+
9
+ ```bash
10
+ pi install npm:pi-devin-auth
11
+ ```
12
+
13
+ Then enable the extension:
14
+
15
+ ```bash
16
+ pi config
17
+ ```
18
+
19
+ ### Manual / local dev
20
+
21
+ ```bash
22
+ pi -e ./extensions/index.ts
23
+ ```
24
+
25
+ Or copy `extensions/index.ts` into `~/.pi/agent/extensions/` for auto-discovery.
26
+
27
+ ## Usage
28
+
29
+ ### Login
30
+
31
+ ```
32
+ /login devin
33
+ ```
34
+
35
+ This opens `https://windsurf.com/windsurf/signin` in your browser. After signing in, the page displays an auth token — paste it into the pi prompt. The extension exchanges it for a long-lived Devin API key via `register.windsurf.com`.
36
+
37
+ ### Select a model
38
+
39
+ ```
40
+ /model devin/swe-1-6
41
+ ```
42
+
43
+ ### Logout
44
+
45
+ ```
46
+ /logout devin
47
+ ```
48
+
49
+ ## How it works
50
+
51
+ ```
52
+ pi --login--> windsurf.com (Auth0) --token--> register.windsurf.com (RegisterUser) --api_key--> ~/.pi/agent/auth.json
53
+ pi --chat--> streamDevin() --> cloud-direct/streamChatEvents() --> server.codeium.com (GetChatMessage gRPC) --> pi events
54
+ ```
55
+
56
+ The extension reuses the battle-tested cloud-direct gRPC layer from [opencode-windsurf-auth](https://github.com/rsvedant/opencode-windsurf-auth) and wraps it in pi's native `streamSimple` + `oauth` extension API.
57
+
58
+ ## Models
59
+
60
+ Models are fetched dynamically from Cognition's `GetCascadeModelConfigs` RPC after login, so the list always reflects what your account tier can access. A static fallback set is included for offline use.
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,69 @@
1
+ /**
2
+ * pi extension entry point for the Devin (Cognition) provider.
3
+ *
4
+ * Registers the `devin` provider with pi's ExtensionAPI, wiring up:
5
+ * - OAuth login via Windsurf's browser sign-in flow (`loginDevin`)
6
+ * - A no-op token refresh (Windsurf api_keys are long-lived)
7
+ * - Static model list (dynamic catalog fetch planned for a future version
8
+ * once we've inspected the gRPC GetCascadeModelConfigs response shape)
9
+ * - Streaming chat completions through Devin Cloud (`streamDevin`)
10
+ *
11
+ * The provider does not set baseUrl/apiKey at the provider level —
12
+ * `streamSimple` handles all routing and auth internally using the
13
+ * OAuth-issued api_key.
14
+ */
15
+
16
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
17
+ import type {
18
+ Api,
19
+ Model,
20
+ OAuthCredentials,
21
+ OAuthLoginCallbacks,
22
+ } from '@earendil-works/pi-ai';
23
+ import { streamDevin } from '../src/stream.js';
24
+ import { loginDevin } from '../src/oauth/login.js';
25
+ import { FALLBACK_MODELS } from '../src/models.js';
26
+ import { DEFAULT_REGION } from '../src/oauth/types.js';
27
+
28
+ const PROVIDER_ID = 'devin';
29
+ const PROVIDER_NAME = 'Devin (Cognition)';
30
+ const OAUTH_NAME = 'Devin (Cognition / Windsurf)';
31
+ const API_IDENTIFIER = 'devin-cloud';
32
+
33
+ export default async function (pi: ExtensionAPI): Promise<void> {
34
+ pi.registerProvider(PROVIDER_ID, {
35
+ name: PROVIDER_NAME,
36
+ api: API_IDENTIFIER,
37
+ // No baseUrl/apiKey at the provider level — streamSimple handles
38
+ // all routing and auth internally via the OAuth-issued api_key.
39
+ models: FALLBACK_MODELS,
40
+ oauth: {
41
+ name: OAUTH_NAME,
42
+ async login(
43
+ callbacks: OAuthLoginCallbacks,
44
+ ): Promise<OAuthCredentials> {
45
+ return loginDevin(callbacks, DEFAULT_REGION);
46
+ },
47
+ async refreshToken(
48
+ credentials: OAuthCredentials,
49
+ ): Promise<OAuthCredentials> {
50
+ // Windsurf's RegisterUser returns a long-lived api_key
51
+ // with no refresh token. The key is effectively
52
+ // non-expiring, so refreshToken is a no-op — return the
53
+ // same credentials.
54
+ return credentials;
55
+ },
56
+ getApiKey(credentials: OAuthCredentials): string {
57
+ return credentials.access;
58
+ },
59
+ // modifyModels is sync in pi v0.80.x, so we can't do an async
60
+ // catalog fetch here. The static FALLBACK_MODELS list is used
61
+ // for v0.1.0. Dynamic catalog fetch will be added in a future
62
+ // version once we've verified the gRPC response shape.
63
+ modifyModels(models: Model<Api>[]): Model<Api>[] {
64
+ return models;
65
+ },
66
+ },
67
+ streamSimple: streamDevin,
68
+ });
69
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "pi-devin-auth",
3
+ "version": "0.1.0",
4
+ "description": "Pi coding agent extension for Devin/Cognition (Windsurf) OAuth + streaming",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "devin",
9
+ "cognition",
10
+ "windsurf",
11
+ "oauth",
12
+ "pi-extension",
13
+ "custom-provider"
14
+ ],
15
+ "author": "nmzpy",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/nmzpy/pi-devin-auth.git"
20
+ },
21
+ "homepage": "https://github.com/nmzpy/pi-devin-auth#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/nmzpy/pi-devin-auth/issues"
24
+ },
25
+ "pi": {
26
+ "extensions": [
27
+ "./extensions/index.ts"
28
+ ]
29
+ },
30
+ "files": [
31
+ "extensions/",
32
+ "src/",
33
+ "README.md",
34
+ "AGENTS.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "node --test --import tsx tests/stream.test.ts"
40
+ },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-ai": "*",
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "@earendil-works/pi-agent-core": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "typebox": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@earendil-works/pi-ai": "*",
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "@types/node": "^25.0.0",
52
+ "tsx": "^4.19.0",
53
+ "typescript": "^5.7.0"
54
+ },
55
+ "engines": {
56
+ "node": ">=18.0.0"
57
+ }
58
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Mint the short-lived `user_jwt` that every chat RPC needs alongside the
3
+ * persistent OAuth-issued `api_key`.
4
+ *
5
+ * POST https://server.codeium.com/exa.auth_pb.AuthService/GetUserJwt
6
+ * Content-Type: application/proto ← unary, NOT streaming
7
+ * Body: GetUserJwtRequest { metadata: Metadata }
8
+ * Response: GetUserJwtResponse { user_jwt: string } (field 1)
9
+ *
10
+ * The returned JWT has a payload like:
11
+ * {
12
+ * "api_key": "devin-synthetic-apikey$account-…$user-…",
13
+ * "auth_uid": "devin-auth-uid$…",
14
+ * "email": "user@example.com",
15
+ * "exp": <unix-seconds>, ← ~24 minute TTL
16
+ * "pro": true,
17
+ * "teams_tier": "TEAMS_TIER_DEVIN_PRO",
18
+ * ...
19
+ * }
20
+ *
21
+ * The JWT is signed HS256 by the server — can't be forged client-side. We
22
+ * cache it and refresh shortly before `exp`.
23
+ */
24
+
25
+ import * as crypto from 'crypto';
26
+ import { encodeMessage, iterFields } from './wire.js';
27
+ import { buildMetadata } from './metadata.js';
28
+
29
+ const DEFAULT_HOST = 'https://server.codeium.com';
30
+
31
+ /**
32
+ * Polyfill for `AbortSignal.any` — composes multiple signals so the result
33
+ * aborts when ANY input aborts. Built-in in Node ≥20.3 / Bun ≥1.0. Our
34
+ * `engines.node` is `>=18.0.0`, so we ship the fallback ourselves; without
35
+ * it the caller's cancel signal silently disappears on older runtimes
36
+ * (chat-cancel during a `GetUserJwt` mint would keep the network request
37
+ * alive for up to the full 30s timeout).
38
+ */
39
+ function anySignal(signals: AbortSignal[]): AbortSignal {
40
+ const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
41
+ if (typeof builtin === 'function') return builtin(signals);
42
+ const controller = new AbortController();
43
+ const onAbort = (reason: unknown): void => {
44
+ if (!controller.signal.aborted) controller.abort(reason);
45
+ };
46
+ for (const s of signals) {
47
+ if (s.aborted) {
48
+ onAbort(s.reason);
49
+ break;
50
+ }
51
+ s.addEventListener('abort', () => onAbort(s.reason), { once: true });
52
+ }
53
+ return controller.signal;
54
+ }
55
+
56
+ export interface MintedUserJwt {
57
+ jwt: string;
58
+ /** Unix epoch seconds when the JWT expires. */
59
+ expiresAt: number;
60
+ }
61
+
62
+ export class CloudAuthError extends Error {
63
+ constructor(message: string, public readonly status?: number) {
64
+ super(message);
65
+ this.name = 'CloudAuthError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Default mint timeout — 30s is generous (the endpoint responds in ~200ms
71
+ * in steady state) but enough headroom for slow networks. Callers can pass
72
+ * a tighter `signal` to override.
73
+ */
74
+ const MINT_TIMEOUT_MS = 30_000;
75
+
76
+ /**
77
+ * Mint a fresh user_jwt by calling exa.auth_pb.AuthService/GetUserJwt.
78
+ * `host` defaults to https://server.codeium.com — pass your tenant URL if your
79
+ * RegisterUser response gave a different host.
80
+ *
81
+ * Always applies an internal 30s timeout so a network stall here can't
82
+ * deadlock every concurrent chat request. If the caller passes a `signal`,
83
+ * we honor whichever fires first via AbortSignal.any.
84
+ */
85
+ export async function mintUserJwt(
86
+ apiKey: string,
87
+ host: string = DEFAULT_HOST,
88
+ signal?: AbortSignal,
89
+ ): Promise<MintedUserJwt> {
90
+ const metadata = buildMetadata({
91
+ apiKey,
92
+ sessionId: crypto.randomUUID(),
93
+ requestId: BigInt(Date.now()),
94
+ triggerId: crypto.randomUUID(),
95
+ });
96
+ // GetUserJwtRequest { metadata: Metadata } — Metadata is field 1
97
+ const req = encodeMessage(1, metadata);
98
+
99
+ // Compose caller signal with our internal timeout via `anySignal` — a
100
+ // small polyfill of `AbortSignal.any` for runtimes (Node 18 / older
101
+ // Bun) that lack the built-in. The previous fallback silently dropped
102
+ // the CALLER's signal on those runtimes, so a chat-cancel during a
103
+ // GetUserJwt mint would keep the network request alive for up to the
104
+ // full 30s timeout.
105
+ const timeoutSignal = AbortSignal.timeout(MINT_TIMEOUT_MS);
106
+ const combinedSignal: AbortSignal = signal
107
+ ? anySignal([signal, timeoutSignal])
108
+ : timeoutSignal;
109
+
110
+ const resp = await fetch(`${host.replace(/\/$/, '')}/exa.auth_pb.AuthService/GetUserJwt`, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/proto',
114
+ 'Connect-Protocol-Version': '1',
115
+ },
116
+ body: req,
117
+ signal: combinedSignal,
118
+ });
119
+ const buf = Buffer.from(await resp.arrayBuffer());
120
+
121
+ if (!resp.ok) {
122
+ const text = buf.toString('utf8');
123
+ throw new CloudAuthError(`GetUserJwt HTTP ${resp.status}: ${text.slice(0, 400)}`, resp.status);
124
+ }
125
+
126
+ // Response is GetUserJwtResponse { user_jwt: string } where user_jwt is
127
+ // field 1, length-delimited. Decode the field properly instead of
128
+ // regex-scanning the whole buffer — the previous regex would pick up
129
+ // any JWT-shaped substring in the response (trace IDs, signature
130
+ // headers, any cached token inadvertently logged) and could even land
131
+ // on a non-user_jwt if Cognition ever embeds another JWT in a sibling
132
+ // field.
133
+ let jwt: string | null = null;
134
+ for (const f of iterFields(buf)) {
135
+ if (f.num === 1 && f.wire === 2 && Buffer.isBuffer(f.value)) {
136
+ const s = (f.value as Buffer).toString('utf8');
137
+ // Sanity-check the shape — defensive: if the cloud ever moves user_jwt
138
+ // out from field 1 we want a clean error, not silently wrong creds.
139
+ // base64url with OPTIONAL `=` padding on each segment. Most modern
140
+ // JWTs omit the `=`, but the spec allows it and a future server-side
141
+ // change could re-introduce it; either way it's still a valid token.
142
+ if (/^eyJ[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]+={0,2}\.[A-Za-z0-9_-]+={0,2}$/.test(s)) {
143
+ jwt = s;
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ if (!jwt) {
149
+ throw new CloudAuthError(
150
+ `GetUserJwt 200 but no field-1 JWT found (${buf.length} bytes): ${buf.toString('utf8').slice(0, 200)}`,
151
+ );
152
+ }
153
+
154
+ // Decode the payload to get the expiry.
155
+ let expiresAt = Math.floor(Date.now() / 1000) + 600; // fallback: 10 min
156
+ try {
157
+ const parts = jwt.split('.');
158
+ const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
159
+ const payload = JSON.parse(
160
+ Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'),
161
+ );
162
+ if (typeof payload.exp === 'number') expiresAt = payload.exp;
163
+ } catch { /* fall back to default */ }
164
+
165
+ return { jwt, expiresAt };
166
+ }
167
+
168
+ // ----------------------------------------------------------------------------
169
+ // In-memory cache — refresh ~60s before expiry
170
+ // ----------------------------------------------------------------------------
171
+
172
+ interface CacheEntry {
173
+ jwt: string;
174
+ expiresAt: number;
175
+ apiKey: string;
176
+ host: string;
177
+ }
178
+
179
+ /**
180
+ * Cache is keyed by (apiKey, host). A single shared `cache` slot only holds
181
+ * the MOST RECENTLY USED entry — common case is one account at a time, so
182
+ * a single slot is enough. inFlight is a per-key map so a JWT mint for
183
+ * account A doesn't get returned to a concurrent request for account B.
184
+ *
185
+ * Previously `inFlight` was a singleton — if account A's mint was in flight
186
+ * and a request for account B arrived, B got A's JWT. That's the M1
187
+ * "concurrent requests after account switch get wrong JWT" bug.
188
+ */
189
+ let cache: CacheEntry | null = null;
190
+ const inFlight = new Map<string, Promise<MintedUserJwt>>();
191
+ /**
192
+ * Monotonic epoch counter. Incremented on every `clearCachedUserJwt()`
193
+ * call so an in-flight mint that started BEFORE the clear can't
194
+ * repopulate the cache after-the-fact. Without this, a logout that
195
+ * happened concurrently with a mint would silently get its just-
196
+ * invalidated JWT cached and served for the next ~24 minutes.
197
+ */
198
+ let cacheEpoch = 0;
199
+
200
+ function flightKey(apiKey: string, host: string): string {
201
+ return `${host}\x1f${apiKey}`;
202
+ }
203
+
204
+ /**
205
+ * Get a cached user_jwt or mint a new one. Refreshes when the cached JWT is
206
+ * within 60s of expiry. Multiple concurrent callers for the SAME (apiKey, host)
207
+ * share the same in-flight mint; concurrent callers for DIFFERENT keys each
208
+ * get their own mint.
209
+ */
210
+ export async function getCachedUserJwt(apiKey: string, host: string = DEFAULT_HOST, signal?: AbortSignal): Promise<string> {
211
+ const now = Math.floor(Date.now() / 1000);
212
+ if (cache && cache.apiKey === apiKey && cache.host === host && cache.expiresAt > now + 60) {
213
+ return cache.jwt;
214
+ }
215
+ const key = flightKey(apiKey, host);
216
+ const existing = inFlight.get(key);
217
+ if (existing) return (await existing).jwt;
218
+ const promise = mintUserJwt(apiKey, host, signal);
219
+ inFlight.set(key, promise);
220
+ // Snapshot the epoch BEFORE awaiting the mint. If clearCachedUserJwt()
221
+ // fires while we're awaiting (logout-during-mint), the epoch changes
222
+ // and we won't repopulate the cache with the just-invalidated JWT.
223
+ const epochAtStart = cacheEpoch;
224
+ try {
225
+ const minted = await promise;
226
+ if (cacheEpoch === epochAtStart) {
227
+ cache = { jwt: minted.jwt, expiresAt: minted.expiresAt, apiKey, host };
228
+ }
229
+ return minted.jwt;
230
+ } finally {
231
+ inFlight.delete(key);
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Drop the in-memory JWT cache. Call after credential changes (logout,
237
+ * account switch) so long-running opencode processes don't keep using a
238
+ * JWT minted from a now-invalid api_key. Also bumps the cache epoch so
239
+ * any in-flight mint racing with this clear can't repopulate cache
240
+ * with the stale JWT after-the-fact.
241
+ */
242
+ export function clearCachedUserJwt(): void {
243
+ cache = null;
244
+ inFlight.clear();
245
+ cacheEpoch++;
246
+ }