@nmzpy/pi-ember-stack 0.1.1 → 0.1.2

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.
Files changed (41) hide show
  1. package/ATTRIBUTION.md +7 -2
  2. package/README.md +90 -69
  3. package/package.json +49 -50
  4. package/plugins/devin-auth/AGENTS.md +63 -0
  5. package/plugins/devin-auth/LICENSE +21 -0
  6. package/plugins/devin-auth/README.md +56 -0
  7. package/plugins/devin-auth/extensions/index.ts +141 -0
  8. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -0
  9. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -0
  10. package/plugins/devin-auth/src/cloud-direct/chat.ts +1091 -0
  11. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -0
  12. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -0
  13. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -0
  14. package/plugins/devin-auth/src/context-map.ts +170 -0
  15. package/plugins/devin-auth/src/models.ts +236 -0
  16. package/plugins/devin-auth/src/oauth/login.ts +95 -0
  17. package/plugins/devin-auth/src/oauth/register-user.ts +174 -0
  18. package/plugins/devin-auth/src/oauth/types.ts +71 -0
  19. package/plugins/devin-auth/src/stream.ts +341 -0
  20. package/{src/pi-ember-stack.ts → plugins/ember/index.ts} +2 -2
  21. package/plugins/index.ts +138 -0
  22. package/{src → plugins}/subagent/extensions/model.ts +96 -96
  23. package/tsconfig.json +2 -1
  24. /package/{src → plugins/ember}/questionnaire-tool.ts +0 -0
  25. /package/{src → plugins}/subagent/LICENSE +0 -0
  26. /package/{src → plugins}/subagent/README.md +0 -0
  27. /package/{src → plugins}/subagent/agent-format.md +0 -0
  28. /package/{src → plugins}/subagent/agents/architect.md +0 -0
  29. /package/{src → plugins}/subagent/agents/coder.md +0 -0
  30. /package/{src → plugins}/subagent/agents/general-purpose.md +0 -0
  31. /package/{src → plugins}/subagent/agents/reviewer.md +0 -0
  32. /package/{src → plugins}/subagent/agents/scout.md +0 -0
  33. /package/{src → plugins}/subagent/agents/worker.md +0 -0
  34. /package/{src → plugins}/subagent/extensions/agents.ts +0 -0
  35. /package/{src → plugins}/subagent/extensions/index.ts +0 -0
  36. /package/{src → plugins}/subagent/extensions/package.json +0 -0
  37. /package/{src → plugins}/subagent/extensions/render.ts +0 -0
  38. /package/{src → plugins}/subagent/extensions/runner.ts +0 -0
  39. /package/{src → plugins}/subagent/extensions/service.ts +0 -0
  40. /package/{src → plugins}/subagent/extensions/thread-viewer.ts +0 -0
  41. /package/{src → plugins}/subagent/extensions/threads.ts +0 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Model list for the Devin (Cognition) provider.
3
+ *
4
+ * Two paths:
5
+ * 1. {@link FALLBACK_MODELS} — 11 static entries shown before login or
6
+ * when the live catalog fetch fails. Carries real pricing.
7
+ * 2. {@link buildLiveModels} — filters the live `GetCascadeModelConfigs`
8
+ * catalog to the 11 model families we care about, stamping each entry
9
+ * with pricing/metadata from {@link MODEL_OVERRIDES}.
10
+ *
11
+ * The catalog only carries `modelUid`, `label`, and `disabled` — it does
12
+ * NOT include context window, max output tokens, reasoning capability, or
13
+ * pricing. We supply all of those from {@link MODEL_OVERRIDES}, keyed by
14
+ * prefix so variants (e.g. `claude-opus-4-8-medium`, `gpt-5-6-sol-low`)
15
+ * inherit the correct metadata from their parent family.
16
+ */
17
+
18
+ import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent';
19
+ import type { CacheEntry } from './cloud-direct/index.js';
20
+
21
+ /** Default Cognition/Codeium host. */
22
+ const DEFAULT_HOST = 'https://server.codeium.com';
23
+
24
+ /**
25
+ * Prefixes of the 11 model families we want from the live catalog.
26
+ * Prefix matching catches variants (e.g. `swe-1-7-lightning`,
27
+ * `gpt-5-6-sol-low`, `claude-opus-4-8-high`).
28
+ */
29
+ export const WANTED_PREFIXES: readonly string[] = [
30
+ 'swe-1-7',
31
+ 'gpt-5-6-sol',
32
+ 'gpt-5-6-luna',
33
+ 'gpt-5-6-terra',
34
+ 'claude-opus-4-8',
35
+ 'claude-fable-5',
36
+ 'claude-sonnet-5',
37
+ 'glm-5-2',
38
+ 'kimi-k2-7',
39
+ 'grok-4-5',
40
+ ];
41
+
42
+ function matchesWantedPrefix(uid: string): boolean {
43
+ for (const prefix of WANTED_PREFIXES) {
44
+ if (uid === prefix || uid.startsWith(prefix + '-') || uid.startsWith(prefix + '_')) {
45
+ return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Per-model metadata + pricing, keyed by prefix.
53
+ *
54
+ * All prices are per million tokens (USD). Used by both
55
+ * {@link FALLBACK_MODELS} and {@link buildLiveModels} so there is a single
56
+ * source of truth for pricing.
57
+ */
58
+ interface ModelMeta {
59
+ contextWindow: number;
60
+ maxTokens: number;
61
+ reasoning: boolean;
62
+ input: ('text' | 'image')[];
63
+ cost: {
64
+ input: number;
65
+ output: number;
66
+ cacheRead: number;
67
+ cacheWrite: number;
68
+ };
69
+ }
70
+
71
+ const MODEL_META: Map<string, ModelMeta> = new Map([
72
+ ['swe-1-7', {
73
+ contextWindow: 256_000,
74
+ maxTokens: 128_000,
75
+ reasoning: true,
76
+ input: ['text', 'image'],
77
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
78
+ }],
79
+ ['swe-1-7-lightning', {
80
+ contextWindow: 256_000,
81
+ maxTokens: 128_000,
82
+ reasoning: true,
83
+ input: ['text', 'image'],
84
+ cost: { input: 2.50, output: 12.50, cacheRead: 0.25, cacheWrite: 3.13 },
85
+ }],
86
+ ['gpt-5-6-sol', {
87
+ contextWindow: 1_050_000,
88
+ maxTokens: 128_000,
89
+ reasoning: true,
90
+ input: ['text', 'image'],
91
+ cost: { input: 5.00, output: 30.00, cacheRead: 0.50, cacheWrite: 6.25 },
92
+ }],
93
+ ['gpt-5-6-luna', {
94
+ contextWindow: 1_050_000,
95
+ maxTokens: 128_000,
96
+ reasoning: true,
97
+ input: ['text', 'image'],
98
+ cost: { input: 1.00, output: 6.00, cacheRead: 0.10, cacheWrite: 1.25 },
99
+ }],
100
+ ['gpt-5-6-terra', {
101
+ contextWindow: 1_050_000,
102
+ maxTokens: 128_000,
103
+ reasoning: true,
104
+ input: ['text', 'image'],
105
+ cost: { input: 2.50, output: 15.00, cacheRead: 0.25, cacheWrite: 3.13 },
106
+ }],
107
+ ['claude-opus-4-8', {
108
+ contextWindow: 200_000,
109
+ maxTokens: 128_000,
110
+ reasoning: true,
111
+ input: ['text', 'image'],
112
+ cost: { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
113
+ }],
114
+ ['claude-fable-5', {
115
+ contextWindow: 1_000_000,
116
+ maxTokens: 128_000,
117
+ reasoning: true,
118
+ input: ['text', 'image'],
119
+ cost: { input: 10.00, output: 50.00, cacheRead: 1.00, cacheWrite: 12.50 },
120
+ }],
121
+ ['claude-sonnet-5', {
122
+ contextWindow: 200_000,
123
+ maxTokens: 64_000,
124
+ reasoning: true,
125
+ input: ['text', 'image'],
126
+ cost: { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
127
+ }],
128
+ ['glm-5-2', {
129
+ contextWindow: 1_000_000,
130
+ maxTokens: 131_000,
131
+ reasoning: true,
132
+ input: ['text', 'image'],
133
+ cost: { input: 0.70, output: 2.20, cacheRead: 0.26, cacheWrite: 0.88 },
134
+ }],
135
+ ['kimi-k2-7', {
136
+ contextWindow: 256_000,
137
+ maxTokens: 256_000,
138
+ reasoning: true,
139
+ input: ['text', 'image'],
140
+ cost: { input: 0.95, output: 4.00, cacheRead: 0.19, cacheWrite: 1.19 },
141
+ }],
142
+ ['grok-4-5', {
143
+ contextWindow: 500_000,
144
+ maxTokens: 128_000,
145
+ reasoning: true,
146
+ input: ['text', 'image'],
147
+ cost: { input: 2.00, output: 6.00, cacheRead: 0.50, cacheWrite: 2.50 },
148
+ }],
149
+ ]);
150
+
151
+ /**
152
+ * Find the best-matching metadata for a UID by longest-prefix match
153
+ * against {@link MODEL_META}. This lets `swe-1-7-lightning` match the
154
+ * `swe-1-7-lightning` entry (specific) and `gpt-5-6-sol-low` match the
155
+ * `gpt-5-6-sol` entry (parent family).
156
+ */
157
+ function findMeta(uid: string): ModelMeta | undefined {
158
+ let best: { key: string; meta: ModelMeta } | null = null;
159
+ for (const [key, meta] of MODEL_META) {
160
+ if (uid === key || uid.startsWith(key + '-') || uid.startsWith(key + '_')) {
161
+ if (!best || key.length > best.key.length) {
162
+ best = { key, meta };
163
+ }
164
+ }
165
+ }
166
+ return best?.meta;
167
+ }
168
+
169
+ /** Conservative defaults for catalog UIDs not in the override table. */
170
+ const DEFAULT_META: ModelMeta = {
171
+ contextWindow: 256_000,
172
+ maxTokens: 128_000,
173
+ reasoning: true,
174
+ input: ['text', 'image'],
175
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
176
+ };
177
+
178
+ function makeModel(id: string, name: string, meta: ModelMeta): ProviderModelConfig {
179
+ return {
180
+ id,
181
+ name,
182
+ reasoning: meta.reasoning,
183
+ input: meta.input,
184
+ cost: meta.cost,
185
+ contextWindow: meta.contextWindow,
186
+ maxTokens: meta.maxTokens,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Static fallback list — 11 models with real pricing.
192
+ * Shown before login or when the live catalog fetch fails.
193
+ */
194
+ export const FALLBACK_MODELS: ProviderModelConfig[] = [
195
+ makeModel('swe-1-7', 'SWE-1.7', MODEL_META.get('swe-1-7')!),
196
+ makeModel('swe-1-7-lightning', 'SWE-1.7 Lightning', MODEL_META.get('swe-1-7-lightning')!),
197
+ makeModel('gpt-5-6-sol', 'GPT-5.6 Sol', MODEL_META.get('gpt-5-6-sol')!),
198
+ makeModel('gpt-5-6-luna', 'GPT-5.6 Luna', MODEL_META.get('gpt-5-6-luna')!),
199
+ makeModel('gpt-5-6-terra', 'GPT-5.6 Terra', MODEL_META.get('gpt-5-6-terra')!),
200
+ makeModel('claude-opus-4-8', 'Claude Opus 4.8', MODEL_META.get('claude-opus-4-8')!),
201
+ makeModel('claude-fable-5', 'Claude Fable 5', MODEL_META.get('claude-fable-5')!),
202
+ makeModel('claude-sonnet-5', 'Claude Sonnet 5', MODEL_META.get('claude-sonnet-5')!),
203
+ makeModel('glm-5-2', 'GLM-5.2', MODEL_META.get('glm-5-2')!),
204
+ makeModel('kimi-k2-7', 'Kimi K2.7', MODEL_META.get('kimi-k2-7')!),
205
+ makeModel('grok-4-5', 'Grok 4.5', MODEL_META.get('grok-4-5')!),
206
+ ];
207
+
208
+ /**
209
+ * Build the model list from a live catalog response.
210
+ *
211
+ * Filters to the 11 wanted families via {@link WANTED_PREFIXES}, skips
212
+ * disabled entries, and stamps each with pricing/metadata from
213
+ * {@link MODEL_META}. Falls back to {@link FALLBACK_MODELS} when the
214
+ * catalog is null, empty, or contains none of our wanted models.
215
+ */
216
+ export function buildLiveModels(catalog: CacheEntry | null): ProviderModelConfig[] {
217
+ if (!catalog || catalog.byUid.size === 0) {
218
+ return FALLBACK_MODELS;
219
+ }
220
+
221
+ const models: ProviderModelConfig[] = [];
222
+ for (const entry of catalog.byUid.values()) {
223
+ if (entry.disabled) continue;
224
+ if (!matchesWantedPrefix(entry.modelUid)) continue;
225
+ const meta = findMeta(entry.modelUid) ?? DEFAULT_META;
226
+ models.push(makeModel(entry.modelUid, entry.label || entry.modelUid, meta));
227
+ }
228
+
229
+ if (models.length === 0) {
230
+ return FALLBACK_MODELS;
231
+ }
232
+
233
+ return models;
234
+ }
235
+
236
+ export { DEFAULT_HOST };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Drive a browser-based Windsurf sign-in via pi's `OAuthLoginCallbacks`.
3
+ *
4
+ * Flow:
5
+ * 1. Build an Auth0 implicit-grant URL (`response_type=token`) pointing at
6
+ * Windsurf's SPA sign-in page with `redirect_uri=show-auth-token`. That
7
+ * special redirect tells the SPA to render the resulting access token in
8
+ * a `<code>` block on screen instead of redirecting away — the user
9
+ * copies it manually.
10
+ * 2. `callbacks.onAuth({ url })` opens the browser.
11
+ * 3. `callbacks.onPrompt(...)` collects the pasted token. That pasted value
12
+ * IS the Firebase ID token (the `access_token` from the OAuth fragment).
13
+ * 4. `registerUser(pasted, region)` exchanges it for a long-lived API key.
14
+ * 5. We wrap the key in `OAuthCredentials` and return it.
15
+ *
16
+ * Why manual paste instead of a loopback redirect? pi's `OAuthLoginCallbacks`
17
+ * surface no way to spin up a local HTTP server or read a redirect — the only
18
+ * inputs we get back are `onPrompt` (free text) and `onSelect` (pick from a
19
+ * list). The `show-auth-token` redirect + manual paste is the same trick the
20
+ * opencode-windsurf-auth CLI uses for headless / SSH environments, and it is
21
+ * the only shape that fits pi's callback contract.
22
+ */
23
+
24
+ import * as crypto from 'crypto';
25
+ import type { OAuthCredentials, OAuthLoginCallbacks } from '@earendil-works/pi-ai';
26
+ import { registerUser } from './register-user.js';
27
+ import { DEFAULT_REGION, type WindsurfRegion } from './types.js';
28
+
29
+ /** One year in milliseconds — the API key is effectively non-expiring. */
30
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
31
+
32
+ /**
33
+ * Build the Auth0 implicit-grant URL for Windsurf's SPA sign-in page.
34
+ *
35
+ * Mirrors the params the opencode-windsurf-auth flow sends:
36
+ * - `response_type=token` -> Auth0 returns the access token in the URL
37
+ * fragment (implicit grant, no client secret).
38
+ * - `redirect_uri=show-auth-token` -> the SPA's special route that renders
39
+ * the token on-screen for manual copy instead of bouncing elsewhere.
40
+ * - `state` -> random UUID, guards CSRF on the browser round-trip. We do
41
+ * not validate it server-side (we never receive the redirect), but
42
+ * Auth0 requires it and echoing it back is good hygiene.
43
+ * - `prompt=login` -> force a fresh Auth0 login screen even if the user
44
+ * has an SSO session, so they can pick a different account.
45
+ */
46
+ function buildSignInUrl(region: WindsurfRegion): string {
47
+ const params = new URLSearchParams({
48
+ response_type: 'token',
49
+ client_id: region.oauthClientId,
50
+ redirect_uri: 'show-auth-token',
51
+ state: crypto.randomUUID(),
52
+ prompt: 'login',
53
+ });
54
+ return `${region.website}/windsurf/signin?${params.toString()}`;
55
+ }
56
+
57
+ /**
58
+ * Run the full Windsurf OAuth login through pi's `OAuthLoginCallbacks`.
59
+ *
60
+ * The returned `OAuthCredentials.access` is the Windsurf API key (not the
61
+ * short-lived Firebase token — that is consumed by `registerUser` and
62
+ * discarded). `refresh` is empty because Windsurf API keys do not expire;
63
+ * `expires` is set one year out as a soft sentinel, not a hard expiry.
64
+ *
65
+ * `WindsurfRegistrationError` thrown by `registerUser` is allowed to
66
+ * propagate — pi surfaces it to the user.
67
+ */
68
+ export async function loginDevin(
69
+ callbacks: OAuthLoginCallbacks,
70
+ region: WindsurfRegion = DEFAULT_REGION,
71
+ ): Promise<OAuthCredentials> {
72
+ const url = buildSignInUrl(region);
73
+
74
+ // Open the browser to the Auth0 sign-in page.
75
+ callbacks.onAuth({ url });
76
+
77
+ // Block until the user pastes the token rendered on the sign-in page.
78
+ // This is the Firebase ID token (OAuth `access_token` from the fragment).
79
+ const firebaseIdToken = await callbacks.onPrompt({
80
+ message: 'Paste the token from the sign-in page:',
81
+ });
82
+
83
+ if (!firebaseIdToken) {
84
+ throw new Error('No token pasted; cannot complete sign-in.');
85
+ }
86
+
87
+ // Exchange the Firebase token for a long-lived Windsurf API key.
88
+ const result = await registerUser(firebaseIdToken.trim(), region);
89
+
90
+ return {
91
+ refresh: '',
92
+ access: result.apiKey,
93
+ expires: Date.now() + ONE_YEAR_MS,
94
+ };
95
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Exchange a Firebase ID token for a long-lived Windsurf API key.
3
+ *
4
+ * This calls the same Connect-RPC endpoint the Windsurf desktop extension uses
5
+ * after the browser sign-in completes:
6
+ *
7
+ * POST https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser
8
+ * Content-Type: application/json
9
+ * Body: { "firebase_id_token": "<jwt>" }
10
+ *
11
+ * Connect-RPC happily accepts plain JSON over HTTPS (no gRPC framing required),
12
+ * so we skip @connectrpc/connect entirely and use `fetch`. The response shape
13
+ * matches `exa.seat_management_pb.RegisterUserResponse`:
14
+ *
15
+ * { api_key, name, api_server_url, redirect_url, team_options[] }
16
+ *
17
+ * Endpoint verified live (returns `{code:"unauthenticated",message:"invalid token ..."}`
18
+ * for a fake token, 200 with the response body for a valid one).
19
+ */
20
+
21
+ import type { OAuthLoginResult, WindsurfRegion } from './types.js';
22
+
23
+ /**
24
+ * Polyfill for `AbortSignal.any` — composes multiple signals so the result
25
+ * aborts when ANY input aborts. Built-in in Node ≥20.3 / Bun ≥1.0; we
26
+ * implement the fallback ourselves so the timeout/caller-signal merge
27
+ * works on every runtime our `engines` field permits (Node 18+).
28
+ */
29
+ function anySignal(signals: AbortSignal[]): AbortSignal {
30
+ const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
31
+ if (typeof builtin === 'function') return builtin(signals);
32
+ const controller = new AbortController();
33
+ const onAbort = (reason: unknown): void => {
34
+ if (!controller.signal.aborted) controller.abort(reason);
35
+ };
36
+ for (const s of signals) {
37
+ if (s.aborted) {
38
+ onAbort(s.reason);
39
+ break;
40
+ }
41
+ s.addEventListener('abort', () => onAbort(s.reason), { once: true });
42
+ }
43
+ return controller.signal;
44
+ }
45
+
46
+ interface RegisterUserResponseJson {
47
+ api_key?: string;
48
+ name?: string;
49
+ api_server_url?: string;
50
+ redirect_url?: string;
51
+ team_options?: unknown[];
52
+ }
53
+
54
+ interface ConnectErrorJson {
55
+ code?: string;
56
+ message?: string;
57
+ }
58
+
59
+ export class WindsurfRegistrationError extends Error {
60
+ readonly status: number;
61
+ readonly connectCode?: string;
62
+ readonly traceId?: string;
63
+
64
+ constructor(message: string, status: number, connectCode?: string, traceId?: string) {
65
+ super(message);
66
+ this.name = 'WindsurfRegistrationError';
67
+ this.status = status;
68
+ this.connectCode = connectCode;
69
+ this.traceId = traceId;
70
+ }
71
+ }
72
+
73
+ const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i;
74
+
75
+ /**
76
+ * Exchange the Firebase ID token for a Windsurf API key.
77
+ *
78
+ * `firebaseIdToken` is the `access_token` (or `firebase_id_token`) value the
79
+ * Windsurf sign-in page returns in the OAuth callback URL — we treat it as
80
+ * opaque.
81
+ */
82
+ export async function registerUser(
83
+ firebaseIdToken: string,
84
+ region: WindsurfRegion,
85
+ abortSignal?: AbortSignal,
86
+ ): Promise<OAuthLoginResult> {
87
+ if (!firebaseIdToken) {
88
+ throw new WindsurfRegistrationError('Empty firebase_id_token', 0, 'invalid_argument');
89
+ }
90
+
91
+ const url = `${region.registerApiServerUrl.replace(/\/$/, '')}/exa.seat_management_pb.SeatManagementService/RegisterUser`;
92
+
93
+ // 30s internal timeout — RegisterUser responds in ~200ms in steady state.
94
+ // CLI users on flaky networks need bounded waits or the sign-in command
95
+ // hangs forever. Compose with the caller's signal via a small polyfill
96
+ // (`anySignal`) because Node 18 / older Bun lack AbortSignal.any; the
97
+ // previous fallback `combinedSignal = abortSignal` would drop the
98
+ // timeout entirely on those runtimes.
99
+ const timeoutSignal = AbortSignal.timeout(30_000);
100
+ const combinedSignal: AbortSignal = abortSignal
101
+ ? anySignal([abortSignal, timeoutSignal])
102
+ : timeoutSignal;
103
+
104
+ const response = await fetch(url, {
105
+ method: 'POST',
106
+ headers: {
107
+ 'Content-Type': 'application/json',
108
+ // Connect protocol version header — not strictly required for JSON, but
109
+ // matches what the official Connect clients send and avoids accidental
110
+ // routing into a non-Connect HTTP handler.
111
+ 'Connect-Protocol-Version': '1',
112
+ },
113
+ body: JSON.stringify({ firebase_id_token: firebaseIdToken }),
114
+ signal: combinedSignal,
115
+ });
116
+
117
+ const text = await response.text();
118
+
119
+ if (!response.ok) {
120
+ let connectCode: string | undefined;
121
+ let message = text || `RegisterUser failed with HTTP ${response.status}`;
122
+ try {
123
+ const errJson = JSON.parse(text) as ConnectErrorJson;
124
+ connectCode = errJson.code;
125
+ if (errJson.message) message = errJson.message;
126
+ } catch {
127
+ // non-JSON error body — keep raw text in `message`
128
+ }
129
+ const traceMatch = message.match(TRACE_ID_RE);
130
+ throw new WindsurfRegistrationError(message, response.status, connectCode, traceMatch?.[1]);
131
+ }
132
+
133
+ let parsed: RegisterUserResponseJson;
134
+ try {
135
+ parsed = JSON.parse(text) as RegisterUserResponseJson;
136
+ } catch {
137
+ throw new WindsurfRegistrationError(
138
+ `RegisterUser returned 200 but body is not JSON: ${text.slice(0, 200)}`,
139
+ response.status,
140
+ 'internal',
141
+ );
142
+ }
143
+
144
+ const apiKey = parsed.api_key;
145
+ const name = parsed.name;
146
+ // Empty `api_server_url` is normal for single-tenant accounts — the desktop
147
+ // extension's `getApiServerUrl` helper falls back to the configured default
148
+ // when this is empty/missing. We mirror that behavior here.
149
+ const apiServerUrl = parsed.api_server_url && parsed.api_server_url.length > 0
150
+ ? parsed.api_server_url
151
+ : 'https://server.codeium.com';
152
+
153
+ if (!apiKey) {
154
+ throw new WindsurfRegistrationError(
155
+ 'RegisterUser returned 200 but api_key was empty',
156
+ response.status,
157
+ 'malformed_response',
158
+ );
159
+ }
160
+ if (!name) {
161
+ throw new WindsurfRegistrationError(
162
+ 'RegisterUser returned 200 but name was empty',
163
+ response.status,
164
+ 'malformed_response',
165
+ );
166
+ }
167
+
168
+ return {
169
+ apiKey,
170
+ name,
171
+ apiServerUrl,
172
+ redirectUrl: parsed.redirect_url,
173
+ };
174
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared types for the OAuth login flow + persisted credentials.
3
+ *
4
+ * Two distinct token shapes appear in this codebase:
5
+ *
6
+ * - `firebaseIdToken` — the short-lived JWT minted by Auth0 / Firebase Auth
7
+ * during browser sign-in. Lives in the OAuth callback URL fragment/query.
8
+ * Treated as opaque and discarded once exchanged.
9
+ *
10
+ * - `apiKey` — the long-lived credential returned by
11
+ * `SeatManagementService.RegisterUser`. Used inside every Cascade RPC's
12
+ * `Metadata.api_key` field. Format is provider-defined:
13
+ * * Cognition era: `devin-session-token$<JWT>`
14
+ * * Codeium classic: bare UUID v4
15
+ * * Older Windsurf: `sk-ws-01-<...>` / `cog_<...>`
16
+ * The plugin treats it as an opaque string — only the cloud cares about format.
17
+ */
18
+
19
+ export interface OAuthLoginResult {
20
+ /** The opaque API key used as `Metadata.api_key` in every Cascade RPC. */
21
+ apiKey: string;
22
+ /** Human-readable account name (`Satvik Kapoor`). */
23
+ name: string;
24
+ /**
25
+ * Cloud API server (`https://server.codeium.com`, `https://eu.windsurf.com/_route/api_server`,
26
+ * `https://windsurf.fedstart.com/_route/api_server`). Driven by the user's
27
+ * tenant — language_server needs this as `--api_server_url`.
28
+ */
29
+ apiServerUrl: string;
30
+ /** Optional cleanup redirect URL returned by RegisterUser. Informational. */
31
+ redirectUrl?: string;
32
+ }
33
+
34
+ export interface PersistedCredentials extends OAuthLoginResult {
35
+ /** ISO timestamp the credentials were minted at — purely informational. */
36
+ issuedAt: string;
37
+ /** Optional tag tracking the OAuth client id used (so a future client rotation can invalidate). */
38
+ oauthClientId: string;
39
+ /**
40
+ * True when these credentials were written as part of the
41
+ * `opencode auth login` → authorize() flow (so opencode's auth.json is the
42
+ * authoritative copy and `opencode auth logout windsurf` should mirror-clear
43
+ * this file). False / absent for credentials written by our standalone
44
+ * `opencode-windsurf-auth login` CLI; those survive opencode auth state
45
+ * changes.
46
+ */
47
+ syncedViaOpencodeAuth?: boolean;
48
+ }
49
+
50
+ export interface WindsurfRegion {
51
+ /** Where to send users for browser sign-in. */
52
+ website: string;
53
+ /** Where to POST RegisterUser. */
54
+ registerApiServerUrl: string;
55
+ /** Auth0 client id passed in the OAuth URL. */
56
+ oauthClientId: string;
57
+ }
58
+
59
+ /**
60
+ * The single tenant (free / personal) configuration. EU, FedStart, and arbitrary
61
+ * portal URLs override `website` + `registerApiServerUrl` at runtime when the
62
+ * user passes `--portal-url` to the login command.
63
+ */
64
+ export const DEFAULT_REGION: WindsurfRegion = {
65
+ website: 'https://windsurf.com',
66
+ registerApiServerUrl: 'https://register.windsurf.com',
67
+ // From /Applications/Windsurf.app/.../extension.js — the public Windsurf
68
+ // Auth0 client. If Windsurf rotates this, sign-in will start failing until
69
+ // we re-extract it.
70
+ oauthClientId: '3GUryQ7ldAeKEuD2obYnppsnmj58eP5u',
71
+ };