@tt-a1i/openpi 0.4.0 → 0.6.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.
Files changed (141) hide show
  1. package/README.md +116 -46
  2. package/SETUP.md +29 -7
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-launch-card-v1.webp +0 -0
  5. package/bin/openpi.js +155 -0
  6. package/extensions/ai-providers/LICENSE.upstream +23 -0
  7. package/extensions/ai-providers/README.md +59 -0
  8. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  9. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  10. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  11. package/extensions/ai-providers/antigravity/models.ts +84 -0
  12. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  13. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  14. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  15. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  16. package/extensions/ai-providers/cursor/constants.ts +5 -0
  17. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  18. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  19. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  20. package/extensions/ai-providers/cursor/models.ts +45 -0
  21. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  22. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  23. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  24. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  25. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +38 -3
  31. package/extensions/background-terminals/src/domain.ts +2 -0
  32. package/extensions/background-terminals/src/manager.ts +484 -106
  33. package/extensions/background-terminals/src/output.ts +33 -0
  34. package/extensions/background-terminals/src/prompt.ts +13 -5
  35. package/extensions/background-terminals/src/result-delivery.ts +47 -24
  36. package/extensions/clear-context/index.ts +83 -0
  37. package/extensions/context-pivot/index.ts +16 -6
  38. package/extensions/cron/index.ts +68 -27
  39. package/extensions/cron/schedule.ts +12 -2
  40. package/extensions/file-mutation-display/render.ts +17 -257
  41. package/extensions/file-search/src/binaries.ts +57 -41
  42. package/extensions/git-read/index.ts +1 -3
  43. package/extensions/model-info/cache-diagnostics.ts +220 -0
  44. package/extensions/model-info/index.ts +65 -33
  45. package/extensions/model-info/session-metrics.ts +96 -0
  46. package/extensions/plan-mode/bash-policy.ts +54 -9
  47. package/extensions/plan-mode/index.ts +82 -6
  48. package/extensions/post-edit/index.ts +16 -6
  49. package/extensions/sessions/git-stats.ts +258 -72
  50. package/extensions/sessions/index.ts +153 -86
  51. package/extensions/sessions/preview-cache.ts +104 -0
  52. package/extensions/sessions/preview-loader.ts +856 -0
  53. package/extensions/sessions/sessions.ts +43 -4
  54. package/extensions/setup/index.ts +138 -130
  55. package/extensions/shared/activity-status.ts +30 -0
  56. package/extensions/shared/agent-session-page.ts +319 -0
  57. package/extensions/shared/agent-tool-renderer.ts +218 -0
  58. package/extensions/shared/agent-transcript.ts +524 -0
  59. package/extensions/shared/capability-intent.ts +1 -1
  60. package/extensions/shared/child-session.ts +457 -21
  61. package/extensions/shared/completion-inbox.ts +193 -0
  62. package/extensions/shared/result-delivery.ts +34 -0
  63. package/extensions/shared/setup-config.ts +83 -34
  64. package/extensions/shared/setup-episode-state.ts +1 -1
  65. package/extensions/shared/structured-output.ts +154 -0
  66. package/extensions/shared/terminal-text.ts +110 -23
  67. package/extensions/shared/text-projection.ts +72 -15
  68. package/extensions/shared/tool-activity.ts +382 -0
  69. package/extensions/shared/tool-surface.ts +29 -2
  70. package/extensions/shared/transcript-viewport.ts +46 -0
  71. package/extensions/shared/web-observer-registry.ts +390 -0
  72. package/extensions/shared/worktree.ts +11 -0
  73. package/extensions/subagents/index.ts +313 -62
  74. package/extensions/subagents/navigation.ts +34 -5
  75. package/extensions/subagents/src/backend.ts +12 -1
  76. package/extensions/subagents/src/backends/pi.ts +450 -70
  77. package/extensions/subagents/src/domain.ts +21 -1
  78. package/extensions/subagents/src/manager.ts +39 -2
  79. package/extensions/subagents/src/prompt.ts +49 -7
  80. package/extensions/subagents/src/result-artifact.ts +36 -0
  81. package/extensions/subagents/src/result-delivery.ts +39 -14
  82. package/extensions/subagents/src/runtime.ts +15 -1
  83. package/extensions/subagents/src/ui/takeover.ts +73 -257
  84. package/extensions/subagents/src/ui/transcript.ts +38 -535
  85. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  86. package/extensions/suggestions/src/ui.ts +10 -4
  87. package/extensions/tasks/index.ts +0 -3
  88. package/extensions/ui-customization/footer.ts +16 -45
  89. package/extensions/ui-customization/index.ts +0 -4
  90. package/extensions/user-input-fold/index.ts +42 -6
  91. package/extensions/web/index.ts +257 -0
  92. package/extensions/workflows/acceptance.ts +43 -19
  93. package/extensions/workflows/artifacts.ts +137 -47
  94. package/extensions/workflows/completion-projection.ts +459 -0
  95. package/extensions/workflows/coordinator.ts +8 -10
  96. package/extensions/workflows/dashboard.ts +175 -228
  97. package/extensions/workflows/handoff.ts +70 -16
  98. package/extensions/workflows/index.ts +501 -198
  99. package/extensions/workflows/journal.ts +148 -13
  100. package/extensions/workflows/model.ts +79 -5
  101. package/extensions/workflows/navigation.ts +32 -8
  102. package/extensions/workflows/progress-projection.ts +306 -0
  103. package/extensions/workflows/prompt.ts +70 -16
  104. package/extensions/workflows/replay-safety.ts +42 -21
  105. package/extensions/workflows/result-delivery.ts +214 -76
  106. package/extensions/workflows/retention.ts +599 -0
  107. package/extensions/workflows/runner.ts +389 -345
  108. package/extensions/workflows/sandbox-child.cjs +25 -3
  109. package/extensions/workflows/sandbox.ts +62 -8
  110. package/extensions/workflows/serialization.ts +325 -17
  111. package/extensions/workflows/tool-renderer.ts +22 -0
  112. package/extensions/workflows/transcript.ts +149 -0
  113. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  114. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  115. package/package.json +34 -14
  116. package/skills/subagents/REFERENCE.md +190 -0
  117. package/skills/subagents/SKILL.md +2 -1
  118. package/skills/workflows/REFERENCE.md +6 -4
  119. package/skills/workflows/SKILL.md +1 -1
  120. package/web/adapter/pi-adapter.ts +664 -0
  121. package/web/host/browser-launcher.ts +20 -0
  122. package/web/host/pi-coding-agent-entry.ts +162 -0
  123. package/web/host/static-assets.ts +4 -0
  124. package/web/host/terminal-status.ts +38 -0
  125. package/web/host/web-host.ts +1069 -0
  126. package/web/http-dispatcher.ts +125 -0
  127. package/web/protocol/types.ts +467 -0
  128. package/web/runtime/pi-runtime.ts +1206 -0
  129. package/web/runtime/types.ts +102 -0
  130. package/web/runtime/web-host-lease.ts +497 -0
  131. package/web/trace.ts +18 -0
  132. package/web/ui/app.js +1700 -0
  133. package/web/ui/index.html +142 -0
  134. package/web/ui/styles.css +680 -0
  135. package/web/vite.config.mjs +34 -0
  136. package/extensions/execution-convergence/active-evidence.ts +0 -129
  137. package/extensions/execution-convergence/index.ts +0 -442
  138. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  139. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  140. package/extensions/setup/intercom.ts +0 -603
  141. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -0,0 +1,700 @@
1
+ /**
2
+ * Google OAuth login + Cloud Code Assist provisioning for Antigravity.
3
+ *
4
+ * Flow (mirrors the real Antigravity client, reference:
5
+ * oh-my-pi packages/ai/src/registry/oauth/google-antigravity.ts):
6
+ *
7
+ * 1. Serve a localhost callback (127.0.0.1:51121/oauth-callback, ephemeral
8
+ * fallback) and hand the Google consent URL to pi via `callbacks.onAuth`.
9
+ * 2. Exchange the authorization code (no PKCE — the Antigravity client is a
10
+ * confidential client with a public client_secret).
11
+ * 3. Provision: loadCodeAssist → (onboardUser free-tier poll when the
12
+ * account has no tier yet) → loadCodeAssist for the project id.
13
+ * 4. Return pi's OAuthCredentials plus { projectId, email } extras; pi
14
+ * persists the whole object in auth.json and hands it back verbatim.
15
+ */
16
+
17
+ import * as http from "node:http";
18
+ import type { AddressInfo } from "node:net";
19
+ import type { OAuthCredentials } from "@earendil-works/pi-ai/compat";
20
+ import type { CancellableOAuthLoginCallbacks } from "../oauth-adapter.ts";
21
+ import type { AntigravityCredentials } from "./credentials.ts";
22
+
23
+ // Public OAuth client identity of the Antigravity IDE (also used by omp and
24
+ // the pi-agy project; it is designed to be embedded in shipped binaries).
25
+ // Stored as base64 so GitHub secret scanning does not treat the public
26
+ // confidential-client pair as a leaked secret.
27
+ const CLIENT_ID = atob(
28
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
29
+ );
30
+ const CLIENT_SECRET = atob("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
31
+
32
+ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
33
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
34
+ const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
35
+
36
+ const SCOPES = [
37
+ "https://www.googleapis.com/auth/cloud-platform",
38
+ "https://www.googleapis.com/auth/userinfo.email",
39
+ "https://www.googleapis.com/auth/userinfo.profile",
40
+ "https://www.googleapis.com/auth/cclog",
41
+ "https://www.googleapis.com/auth/experimentsandconfigs",
42
+ ];
43
+
44
+ const CALLBACK_PORT = 51121;
45
+ const CALLBACK_PATH = "/oauth-callback";
46
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
47
+ const REQUEST_TIMEOUT_MS = 30_000;
48
+
49
+ const CLOUD_CODE_ASSIST_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
50
+ const LOAD_CODE_ASSIST_URL = `${CLOUD_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`;
51
+ const ONBOARD_USER_URL = `${CLOUD_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`;
52
+ const OPERATIONS_URL = `${CLOUD_CODE_ASSIST_ENDPOINT}/v1internal`;
53
+ const FREE_TIER_ID = "free-tier";
54
+ const ONBOARD_TIMEOUT_MS = 30_000;
55
+ const ONBOARD_POLL_INTERVAL_MS = 1_000;
56
+
57
+ /** Refresh tokens are considered stale this long before their actual expiry. */
58
+ const EXPIRY_MARGIN_MS = 5 * 60 * 1000;
59
+
60
+ const LOAD_CODE_ASSIST_METADATA = { ideType: "ANTIGRAVITY" };
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // User-Agent (version-gated upstream; see omp catalog/wire/gemini-headers.ts)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const DEFAULT_ANTIGRAVITY_VERSION = "2.8.0";
67
+ const VERSION_MANIFEST_URL =
68
+ "https://antigravity-hub-auto-updater-974169037036.us-central1.run.app/manifest/latest-arm64-mac.yml";
69
+ const VERSION_FETCH_TIMEOUT_MS = 5_000;
70
+
71
+ let discoveredVersion: string | undefined;
72
+ let versionFetch: Promise<void> | undefined;
73
+
74
+ export function getAntigravityUserAgent(): string {
75
+ const version =
76
+ process.env.OPENPI_ANTIGRAVITY_VERSION ||
77
+ discoveredVersion ||
78
+ DEFAULT_ANTIGRAVITY_VERSION;
79
+ const cl = process.env.OPENPI_ANTIGRAVITY_CL || "963137146";
80
+ return `antigravity/hub/${version} (aidev_client; os_type=darwin; arch=arm64; cl=${cl})`;
81
+ }
82
+
83
+ /** Best-effort version discovery from the official update manifest. */
84
+ export function ensureAntigravityVersion(
85
+ signal?: AbortSignal,
86
+ fetcher: typeof fetch = fetch,
87
+ ): Promise<void> {
88
+ if (process.env.OPENPI_ANTIGRAVITY_VERSION || discoveredVersion) {
89
+ return Promise.resolve();
90
+ }
91
+ if (versionFetch) return versionFetch;
92
+ versionFetch = (async () => {
93
+ try {
94
+ const timeout = AbortSignal.timeout(VERSION_FETCH_TIMEOUT_MS);
95
+ const response = await fetcher(VERSION_MANIFEST_URL, {
96
+ headers: {
97
+ "Cache-Control": "no-cache",
98
+ "User-Agent": "electron-builder",
99
+ },
100
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
101
+ });
102
+ if (response.ok) {
103
+ discoveredVersion = parseManifestVersion(await response.text());
104
+ }
105
+ } catch {
106
+ // Silent: the pinned fallback stays valid when discovery fails.
107
+ } finally {
108
+ if (!discoveredVersion) versionFetch = undefined;
109
+ }
110
+ })();
111
+ return versionFetch;
112
+ }
113
+
114
+ function parseManifestVersion(yamlText: string): string | undefined {
115
+ for (const line of yamlText.split(/\r?\n/)) {
116
+ const match =
117
+ /^\s*version\s*:\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))\s*(?:#.*)?$/.exec(
118
+ line,
119
+ );
120
+ if (!match) continue;
121
+ const version = (match[1] ?? match[2] ?? match[3] ?? "").trim();
122
+ return /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // HTTP helpers
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function throwIfCancelled(signal: AbortSignal | undefined): void {
132
+ if (signal?.aborted) {
133
+ throw new Error(`Login cancelled: ${String(signal.reason ?? "aborted")}`);
134
+ }
135
+ }
136
+
137
+ function withCancellation<T>(
138
+ promise: Promise<T>,
139
+ signal: AbortSignal | undefined,
140
+ ): Promise<T> {
141
+ if (!signal) return promise;
142
+ throwIfCancelled(signal);
143
+ return new Promise<T>((resolve, reject) => {
144
+ const onAbort = () => {
145
+ reject(new Error("Login cancelled"));
146
+ };
147
+ signal.addEventListener("abort", onAbort, { once: true });
148
+ promise.then(
149
+ (value) => {
150
+ signal.removeEventListener("abort", onAbort);
151
+ resolve(value);
152
+ },
153
+ (error) => {
154
+ signal.removeEventListener("abort", onAbort);
155
+ reject(error);
156
+ },
157
+ );
158
+ });
159
+ }
160
+
161
+ /** fetch with login cancellation + per-request timeout composed. */
162
+ async function oauthFetch(
163
+ url: string,
164
+ init: RequestInit,
165
+ signal: AbortSignal | undefined,
166
+ timeoutMs = REQUEST_TIMEOUT_MS,
167
+ ): Promise<Response> {
168
+ const timeout = AbortSignal.timeout(timeoutMs);
169
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
170
+ try {
171
+ return await fetch(url, { ...init, signal: combined });
172
+ } catch (error) {
173
+ throwIfCancelled(signal);
174
+ if (timeout.aborted) {
175
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for ${url}`);
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
182
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
183
+ const onAbort = () => {
184
+ clearTimeout(timer);
185
+ reject(new Error("Login cancelled"));
186
+ };
187
+ const timer = setTimeout(() => {
188
+ signal?.removeEventListener("abort", onAbort);
189
+ resolve();
190
+ }, ms);
191
+ signal?.addEventListener("abort", onAbort, { once: true });
192
+ return promise;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Localhost callback server
197
+ // ---------------------------------------------------------------------------
198
+
199
+ interface CallbackResult {
200
+ code: string;
201
+ state: string;
202
+ }
203
+
204
+ interface CallbackServer {
205
+ port: number;
206
+ waitForCallback(
207
+ expectedState: string,
208
+ signal: AbortSignal | undefined,
209
+ ): Promise<CallbackResult>;
210
+ close(): void;
211
+ }
212
+
213
+ function parseCallbackInput(input: string): CallbackResult | undefined {
214
+ const value = input.trim();
215
+ if (!value) return undefined;
216
+ try {
217
+ const url = new URL(value);
218
+ const code = url.searchParams.get("code");
219
+ if (!code) return undefined;
220
+ return { code, state: url.searchParams.get("state") ?? "" };
221
+ } catch {
222
+ return { code: value, state: "" };
223
+ }
224
+ }
225
+
226
+ const LOGIN_SUCCESS_HTML =
227
+ "<html><body><h2>Antigravity login complete</h2>" +
228
+ "<p>You can return to your terminal.</p></body></html>";
229
+
230
+ const LOGIN_ERROR_HTML =
231
+ "<html><body><h2>Antigravity login failed</h2>" +
232
+ "<p>Missing or mismatched authorization response.</p></body></html>";
233
+
234
+ async function startCallbackServer(): Promise<CallbackServer> {
235
+ const server = http.createServer();
236
+ const pending: ((result: CallbackResult) => void)[] = [];
237
+ const failures: { expectedState: string; reject(error: Error): void }[] = [];
238
+
239
+ server.on("request", (request, response) => {
240
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
241
+ if (url.pathname !== CALLBACK_PATH) {
242
+ response.writeHead(404).end();
243
+ return;
244
+ }
245
+ const code = url.searchParams.get("code");
246
+ const state = url.searchParams.get("state");
247
+ const error = url.searchParams.get("error");
248
+ const matching = state
249
+ ? failures.findIndex((entry) => entry.expectedState === state)
250
+ : -1;
251
+ if (error || !code || matching < 0) {
252
+ response.writeHead(400, { "Content-Type": "text/html" });
253
+ response.end(LOGIN_ERROR_HTML);
254
+ // Only a denial carrying our nonce can terminate the flow. A random
255
+ // local request must not be able to cancel an in-flight login.
256
+ if (error && matching >= 0) {
257
+ const [failure] = failures.splice(matching, 1);
258
+ failure?.reject(new Error(`Authorization failed: ${error}`));
259
+ }
260
+ return;
261
+ }
262
+ response.writeHead(200, { "Content-Type": "text/html" });
263
+ response.end(LOGIN_SUCCESS_HTML);
264
+ for (const resolve of pending.splice(0)) resolve({ code, state: state! });
265
+ failures.length = 0;
266
+ });
267
+
268
+ async function listen(port: number): Promise<number> {
269
+ const { promise, resolve, reject } = Promise.withResolvers<number>();
270
+ server.once("error", reject);
271
+ server.listen(port, "127.0.0.1", () => {
272
+ server.removeListener("error", reject);
273
+ resolve((server.address() as AddressInfo).port);
274
+ });
275
+ return promise;
276
+ }
277
+
278
+ let port: number;
279
+ try {
280
+ port = await listen(CALLBACK_PORT);
281
+ } catch {
282
+ // Preferred port busy: fall back to an ephemeral port; the redirect_uri
283
+ // is built from whatever we actually bound.
284
+ port = await listen(0);
285
+ }
286
+
287
+ return {
288
+ port,
289
+ waitForCallback(expectedState, signal) {
290
+ const { promise, resolve, reject } =
291
+ Promise.withResolvers<CallbackResult>();
292
+ let settled = false;
293
+ let onAbort: (() => void) | undefined;
294
+ const pendingCallback = (result: CallbackResult) => {
295
+ if (result.state !== expectedState) {
296
+ return;
297
+ }
298
+ settle(() => resolve(result));
299
+ };
300
+ const failure = {
301
+ expectedState,
302
+ reject(error: Error) {
303
+ settle(() => reject(error));
304
+ },
305
+ };
306
+ const cleanup = () => {
307
+ const pendingIndex = pending.indexOf(pendingCallback);
308
+ if (pendingIndex >= 0) pending.splice(pendingIndex, 1);
309
+ const failureIndex = failures.indexOf(failure);
310
+ if (failureIndex >= 0) failures.splice(failureIndex, 1);
311
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
312
+ };
313
+ const settle = (complete: () => void) => {
314
+ if (settled) return;
315
+ settled = true;
316
+ cleanup();
317
+ complete();
318
+ };
319
+ pending.push(pendingCallback);
320
+ failures.push(failure);
321
+ if (signal) {
322
+ onAbort = () => settle(() => reject(new Error("Login cancelled")));
323
+ if (signal.aborted) onAbort();
324
+ else signal.addEventListener("abort", onAbort, { once: true });
325
+ }
326
+ return promise;
327
+ },
328
+ close() {
329
+ for (const failure of [...failures]) {
330
+ failure.reject(new Error("Login callback server closed"));
331
+ }
332
+ pending.length = 0;
333
+ failures.length = 0;
334
+ server.close();
335
+ },
336
+ };
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Cloud Code Assist provisioning
341
+ // ---------------------------------------------------------------------------
342
+
343
+ interface LoadCodeAssistResponse {
344
+ currentTier?: { id?: string } | null;
345
+ paidTier?: { id?: string } | null;
346
+ allowedTiers?: { id?: string }[];
347
+ ineligibleTiers?: {
348
+ tierId?: string;
349
+ reasonMessage?: string;
350
+ validationUrl?: string;
351
+ }[];
352
+ cloudaicompanionProject?: string;
353
+ }
354
+
355
+ interface OnboardOperation {
356
+ name?: string;
357
+ done?: boolean;
358
+ error?: { code?: number; message?: string } | null;
359
+ response?: { cloudaicompanionProject?: string } | null;
360
+ }
361
+
362
+ async function postCloudCodeAssist(
363
+ label: string,
364
+ url: string,
365
+ accessToken: string,
366
+ body: Record<string, unknown>,
367
+ signal: AbortSignal | undefined,
368
+ timeoutMs = REQUEST_TIMEOUT_MS,
369
+ ): Promise<unknown> {
370
+ throwIfCancelled(signal);
371
+ const response = await oauthFetch(
372
+ url,
373
+ {
374
+ method: "POST",
375
+ headers: {
376
+ Authorization: `Bearer ${accessToken}`,
377
+ "Content-Type": "application/json",
378
+ "User-Agent": getAntigravityUserAgent(),
379
+ },
380
+ body: JSON.stringify(body),
381
+ },
382
+ signal,
383
+ timeoutMs,
384
+ );
385
+ if (response.status !== 200) {
386
+ const errorText = await response.text();
387
+ throw new Error(
388
+ `${label} failed: ${response.status} ${response.statusText}: ${errorText}`,
389
+ );
390
+ }
391
+ return response.json();
392
+ }
393
+
394
+ function asLoadCodeAssistResponse(payload: unknown): LoadCodeAssistResponse {
395
+ if (payload === null || typeof payload !== "object") {
396
+ throw new Error("loadCodeAssist returned a non-object response");
397
+ }
398
+ return payload as LoadCodeAssistResponse;
399
+ }
400
+
401
+ async function loadCodeAssist(
402
+ accessToken: string,
403
+ signal: AbortSignal | undefined,
404
+ ): Promise<LoadCodeAssistResponse> {
405
+ let payload = asLoadCodeAssistResponse(
406
+ await postCloudCodeAssist(
407
+ "loadCodeAssist",
408
+ LOAD_CODE_ASSIST_URL,
409
+ accessToken,
410
+ { metadata: LOAD_CODE_ASSIST_METADATA },
411
+ signal,
412
+ ),
413
+ );
414
+ const projectId = payload.cloudaicompanionProject;
415
+ if (payload.paidTier === undefined && projectId) {
416
+ payload = asLoadCodeAssistResponse(
417
+ await postCloudCodeAssist(
418
+ "loadCodeAssist",
419
+ LOAD_CODE_ASSIST_URL,
420
+ accessToken,
421
+ {
422
+ cloudaicompanionProject: projectId,
423
+ metadata: LOAD_CODE_ASSIST_METADATA,
424
+ },
425
+ signal,
426
+ ),
427
+ );
428
+ }
429
+ return payload;
430
+ }
431
+
432
+ function assertFreeTierEligible(payload: LoadCodeAssistResponse): void {
433
+ if (payload.allowedTiers?.some((tier) => tier.id === FREE_TIER_ID)) return;
434
+ const ineligible = payload.ineligibleTiers?.find(
435
+ (tier) => tier.tierId === FREE_TIER_ID,
436
+ );
437
+ if (!ineligible?.reasonMessage) return;
438
+ const validation = ineligible.validationUrl
439
+ ? `\n${ineligible.validationUrl}`
440
+ : "";
441
+ throw new Error(`${ineligible.reasonMessage}${validation}`);
442
+ }
443
+
444
+ async function onboardUser(
445
+ accessToken: string,
446
+ signal: AbortSignal | undefined,
447
+ ): Promise<void> {
448
+ const deadline = Date.now() + ONBOARD_TIMEOUT_MS;
449
+ const remaining = () => {
450
+ const left = deadline - Date.now();
451
+ if (left <= 0) {
452
+ throw new Error(`onboardUser timed out after ${ONBOARD_TIMEOUT_MS}ms`);
453
+ }
454
+ return left;
455
+ };
456
+
457
+ let operation = (await postCloudCodeAssist(
458
+ "onboardUser",
459
+ ONBOARD_USER_URL,
460
+ accessToken,
461
+ { tierId: FREE_TIER_ID, metadata: LOAD_CODE_ASSIST_METADATA },
462
+ signal,
463
+ remaining(),
464
+ )) as OnboardOperation;
465
+
466
+ while (true) {
467
+ if (operation.done === true) {
468
+ if (operation.error) {
469
+ const detail =
470
+ operation.error.message ?? JSON.stringify(operation.error);
471
+ throw new Error(`OnboardUser operation failed: ${detail}`);
472
+ }
473
+ return;
474
+ }
475
+ await sleep(Math.min(ONBOARD_POLL_INTERVAL_MS, remaining()), signal);
476
+ throwIfCancelled(signal);
477
+ if (!operation.name) {
478
+ throw new Error("onboardUser returned an operation without a name");
479
+ }
480
+ const polled = await oauthFetch(
481
+ `${OPERATIONS_URL}/${operation.name}`,
482
+ {
483
+ headers: {
484
+ Authorization: `Bearer ${accessToken}`,
485
+ "User-Agent": getAntigravityUserAgent(),
486
+ },
487
+ },
488
+ signal,
489
+ remaining(),
490
+ );
491
+ if (polled.status !== 200) {
492
+ throw new Error(
493
+ `onboardUser poll failed: ${polled.status} ${await polled.text()}`,
494
+ );
495
+ }
496
+ operation = (await polled.json()) as OnboardOperation;
497
+ }
498
+ }
499
+
500
+ async function discoverProject(
501
+ accessToken: string,
502
+ onProgress: ((message: string) => void) | undefined,
503
+ signal: AbortSignal | undefined,
504
+ ): Promise<string> {
505
+ onProgress?.("Checking Cloud Code Assist account status...");
506
+ const initial = await loadCodeAssist(accessToken, signal);
507
+ assertFreeTierEligible(initial);
508
+ if (initial.currentTier === undefined || initial.currentTier === null) {
509
+ onProgress?.("Provisioning the Antigravity free tier...");
510
+ await onboardUser(accessToken, signal);
511
+ }
512
+ onProgress?.("Refreshing Cloud Code Assist project...");
513
+ const refreshed = await loadCodeAssist(accessToken, signal);
514
+ const projectId = refreshed.cloudaicompanionProject;
515
+ if (!projectId) {
516
+ throw new Error("loadCodeAssist did not return a cloudaicompanionProject");
517
+ }
518
+ return projectId;
519
+ }
520
+
521
+ async function fetchUserEmail(
522
+ accessToken: string,
523
+ signal: AbortSignal | undefined,
524
+ ): Promise<string | undefined> {
525
+ try {
526
+ const response = await oauthFetch(
527
+ USERINFO_URL,
528
+ { headers: { Authorization: `Bearer ${accessToken}` } },
529
+ signal,
530
+ );
531
+ if (response.ok) {
532
+ const data: unknown = await response.json();
533
+ if (data !== null && typeof data === "object" && "email" in data) {
534
+ const email = data.email;
535
+ if (typeof email === "string") return email;
536
+ }
537
+ }
538
+ } catch {
539
+ // Email is display metadata only; never fail the login over it.
540
+ }
541
+ return undefined;
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Public flow
546
+ // ---------------------------------------------------------------------------
547
+
548
+ export async function loginAntigravity(
549
+ callbacks: CancellableOAuthLoginCallbacks,
550
+ ): Promise<OAuthCredentials> {
551
+ const signal = callbacks.signal;
552
+ throwIfCancelled(signal);
553
+ await ensureAntigravityVersion(signal);
554
+ throwIfCancelled(signal);
555
+
556
+ const server = await startCallbackServer();
557
+ try {
558
+ throwIfCancelled(signal);
559
+ const state = crypto.randomUUID();
560
+ const redirectUri = `http://127.0.0.1:${server.port}${CALLBACK_PATH}`;
561
+ const authParams = new URLSearchParams({
562
+ client_id: CLIENT_ID,
563
+ response_type: "code",
564
+ redirect_uri: redirectUri,
565
+ scope: SCOPES.join(" "),
566
+ state,
567
+ access_type: "offline",
568
+ prompt: "consent",
569
+ });
570
+ callbacks.onAuth({
571
+ url: `${AUTH_URL}?${authParams.toString()}`,
572
+ instructions: "Complete the Google sign-in in your browser.",
573
+ });
574
+
575
+ const timeout = AbortSignal.timeout(LOGIN_TIMEOUT_MS);
576
+ const loginSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
577
+ const raceController = new AbortController();
578
+ const raceSignal = AbortSignal.any([loginSignal, raceController.signal]);
579
+ let callback: CallbackResult;
580
+ try {
581
+ const callbackPromise = server.waitForCallback(state, raceSignal);
582
+ const onManualCodeInput = callbacks.onManualCodeInput;
583
+ if (onManualCodeInput) {
584
+ const manualPromise = (async () => {
585
+ while (true) {
586
+ const parsed = parseCallbackInput(
587
+ await withCancellation(onManualCodeInput(raceSignal), raceSignal),
588
+ );
589
+ if (parsed && (!parsed.state || parsed.state === state))
590
+ return parsed;
591
+ }
592
+ })();
593
+ callback = await Promise.race([callbackPromise, manualPromise]);
594
+ } else {
595
+ callback = await callbackPromise;
596
+ }
597
+ } catch (error) {
598
+ if (timeout.aborted) {
599
+ throw new Error("Login timed out waiting for the browser callback");
600
+ }
601
+ throw error;
602
+ } finally {
603
+ // The callback and manual prompt are alternatives. Cancel the loser as
604
+ // soon as either one supplies a valid authorization code.
605
+ raceController.abort();
606
+ }
607
+
608
+ callbacks.onProgress?.("Exchanging authorization code for tokens...");
609
+ throwIfCancelled(signal);
610
+ const tokenResponse = await oauthFetch(
611
+ TOKEN_URL,
612
+ {
613
+ method: "POST",
614
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
615
+ body: new URLSearchParams({
616
+ client_id: CLIENT_ID,
617
+ client_secret: CLIENT_SECRET,
618
+ code: callback.code,
619
+ grant_type: "authorization_code",
620
+ redirect_uri: redirectUri,
621
+ }),
622
+ },
623
+ signal,
624
+ );
625
+ if (!tokenResponse.ok) {
626
+ throw new Error(
627
+ `Token exchange failed: ${tokenResponse.status} ${await tokenResponse.text()}`,
628
+ );
629
+ }
630
+ const tokens = (await tokenResponse.json()) as {
631
+ access_token: string;
632
+ expires_in: number;
633
+ refresh_token?: string;
634
+ };
635
+ if (!tokens.refresh_token) {
636
+ throw new Error(
637
+ "Google did not return a refresh token — revoke the app's access " +
638
+ "at myaccount.google.com/permissions and try again.",
639
+ );
640
+ }
641
+
642
+ const projectId = await discoverProject(
643
+ tokens.access_token,
644
+ callbacks.onProgress,
645
+ signal,
646
+ );
647
+ const email = await fetchUserEmail(tokens.access_token, signal);
648
+ throwIfCancelled(signal);
649
+
650
+ const credentials: AntigravityCredentials = {
651
+ refresh: tokens.refresh_token,
652
+ access: tokens.access_token,
653
+ expires: Date.now() + tokens.expires_in * 1000 - EXPIRY_MARGIN_MS,
654
+ projectId,
655
+ email,
656
+ };
657
+ return credentials;
658
+ } finally {
659
+ server.close();
660
+ }
661
+ }
662
+
663
+ export async function refreshAntigravityToken(
664
+ credentials: OAuthCredentials,
665
+ signal: AbortSignal,
666
+ ): Promise<OAuthCredentials> {
667
+ const existing = credentials as AntigravityCredentials;
668
+ const response = await oauthFetch(
669
+ TOKEN_URL,
670
+ {
671
+ method: "POST",
672
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
673
+ body: new URLSearchParams({
674
+ client_id: CLIENT_ID,
675
+ client_secret: CLIENT_SECRET,
676
+ refresh_token: credentials.refresh,
677
+ grant_type: "refresh_token",
678
+ }),
679
+ },
680
+ signal,
681
+ );
682
+ if (!response.ok) {
683
+ throw new Error(
684
+ `Antigravity token refresh failed: ${response.status} ${await response.text()}`,
685
+ );
686
+ }
687
+ const data = (await response.json()) as {
688
+ access_token: string;
689
+ expires_in: number;
690
+ refresh_token?: string;
691
+ };
692
+ const refreshed: AntigravityCredentials = {
693
+ refresh: data.refresh_token || credentials.refresh,
694
+ access: data.access_token,
695
+ expires: Date.now() + data.expires_in * 1000 - EXPIRY_MARGIN_MS,
696
+ projectId: existing.projectId,
697
+ email: existing.email,
698
+ };
699
+ return refreshed;
700
+ }