@nmzpy/pi-ember-stack 0.1.6 → 0.2.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 (36) hide show
  1. package/README.md +83 -83
  2. package/package.json +62 -48
  3. package/plugins/devin-auth/extensions/index.ts +68 -7
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/models.ts +42 -196
  11. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  12. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  13. package/plugins/devin-auth/src/stream.ts +1 -1
  14. package/plugins/index.ts +29 -6
  15. package/plugins/pi-compact-tools/index.ts +35 -192
  16. package/plugins/pi-compact-tools/renderer.ts +589 -0
  17. package/plugins/pi-custom-agents/index.ts +727 -373
  18. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  19. package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
  20. package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
  21. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  22. package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
  23. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  24. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  25. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
  26. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  27. package/plugins/pi-ember-fff/index.ts +975 -0
  28. package/plugins/pi-ember-fff/query.ts +247 -0
  29. package/plugins/pi-ember-fff/test/query.test.ts +222 -0
  30. package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
  31. package/plugins/pi-ember-tps/index.ts +144 -0
  32. package/plugins/pi-ember-ui/ember.json +99 -0
  33. package/plugins/pi-ember-ui/index.ts +805 -0
  34. package/plugins/pi-ember-ui/mode-colors.ts +196 -0
  35. package/tsconfig.json +2 -1
  36. package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
@@ -1,18 +1,13 @@
1
1
  /**
2
2
  * Model list for the Devin (Cognition) provider.
3
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}.
4
+ * Fully live-catalog driven. {@link buildLiveModels} pulls every model from
5
+ * Cognition's `GetCascadeModelConfigs` catalog and surfaces it directly
6
+ * no hardcoded family list, no hardcoded pricing/metadata. The catalog's
7
+ * `label` is used as the display name (with optional overrides below).
10
8
  *
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.
9
+ * Before login (or if the catalog fetch fails) we return an empty list so
10
+ * pi shows no models until the live catalog arrives.
16
11
  */
17
12
 
18
13
  import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent';
@@ -22,212 +17,63 @@ import type { CacheEntry } from './cloud-direct/index.js';
22
17
  const DEFAULT_HOST = 'https://server.codeium.com';
23
18
 
24
19
  /**
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.
20
+ * Per-family variant allow-list. If a family prefix is present, only UIDs
21
+ * whose suffix (the part after `prefix-`) is in the set are kept. A UID that
22
+ * exactly equals the prefix is always allowed (suffix = empty string).
53
23
  *
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.
24
+ * Families not listed here keep all variants.
57
25
  */
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
- }],
26
+ const VARIANT_ALLOW: Map<string, Set<string>> = new Map([
27
+ ['glm-5-2', new Set(['high', 'max'])],
149
28
  ]);
150
29
 
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 };
30
+ function matchesVariantFilter(uid: string): boolean {
31
+ let bestPrefix: string | null = null;
32
+ for (const prefix of VARIANT_ALLOW.keys()) {
33
+ if (uid === prefix || uid.startsWith(prefix + '-') || uid.startsWith(prefix + '_')) {
34
+ if (bestPrefix === null || prefix.length > bestPrefix.length) {
35
+ bestPrefix = prefix;
163
36
  }
164
37
  }
165
38
  }
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
- };
39
+ if (bestPrefix === null) return true;
40
+ if (uid === bestPrefix) return true;
41
+ const suffix = uid.slice(bestPrefix.length + 1);
42
+ return VARIANT_ALLOW.get(bestPrefix)!.has(suffix);
188
43
  }
189
44
 
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
- ];
45
+ /** Display-name overrides for catalog UIDs whose live label is undesired. */
46
+ const NAME_OVERRIDES = new Map<string, string>([
47
+ ['glm-5-2', 'GLM-5.2'],
48
+ ]);
207
49
 
208
50
  /**
209
51
  * Build the model list from a live catalog response.
210
52
  *
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.
53
+ * Surfaces every non-disabled model the catalog returns, applying the variant
54
+ * filter and name overrides above. Returns an empty array before login or
55
+ * when the catalog fetch fails pi will show no models until the live list
56
+ * arrives.
215
57
  */
216
58
  export function buildLiveModels(catalog: CacheEntry | null): ProviderModelConfig[] {
217
59
  if (!catalog || catalog.byUid.size === 0) {
218
- return FALLBACK_MODELS;
60
+ return [];
219
61
  }
220
62
 
221
63
  const models: ProviderModelConfig[] = [];
222
64
  for (const entry of catalog.byUid.values()) {
223
65
  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;
66
+ if (!matchesVariantFilter(entry.modelUid)) continue;
67
+ const name = NAME_OVERRIDES.get(entry.modelUid) ?? entry.label ?? entry.modelUid;
68
+ models.push({
69
+ id: entry.modelUid,
70
+ name,
71
+ reasoning: true,
72
+ input: ['text', 'image'],
73
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
74
+ contextWindow: 256_000,
75
+ maxTokens: 128_000,
76
+ });
231
77
  }
232
78
 
233
79
  return models;
@@ -1,174 +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
- }
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
+ }