@tt-a1i/openpi 0.5.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 (74) hide show
  1. package/README.md +18 -10
  2. package/SETUP.md +8 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/bin/openpi.js +25 -15
  5. package/extensions/ai-providers/LICENSE.upstream +23 -0
  6. package/extensions/ai-providers/README.md +59 -0
  7. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  8. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  9. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  10. package/extensions/ai-providers/antigravity/models.ts +84 -0
  11. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  12. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  13. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  14. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  15. package/extensions/ai-providers/cursor/constants.ts +5 -0
  16. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  17. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  18. package/extensions/ai-providers/cursor/input-images.ts +106 -0
  19. package/extensions/ai-providers/cursor/models.ts +45 -0
  20. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  21. package/extensions/ai-providers/cursor/proto.ts +1064 -0
  22. package/extensions/ai-providers/cursor/protobuf.ts +1171 -0
  23. package/extensions/ai-providers/cursor/provider.ts +1175 -0
  24. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  25. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  26. package/extensions/ai-providers/index.ts +86 -0
  27. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  28. package/extensions/ai-providers/usage.ts +10 -0
  29. package/extensions/background-terminals/index.ts +8 -1
  30. package/extensions/background-terminals/src/manager.ts +3 -5
  31. package/extensions/background-terminals/src/result-delivery.ts +43 -23
  32. package/extensions/cron/index.ts +68 -27
  33. package/extensions/cron/schedule.ts +5 -1
  34. package/extensions/model-info/cache-diagnostics.ts +220 -0
  35. package/extensions/model-info/index.ts +45 -1
  36. package/extensions/plan-mode/index.ts +75 -4
  37. package/extensions/setup/index.ts +15 -3
  38. package/extensions/shared/child-session.ts +25 -5
  39. package/extensions/shared/completion-inbox.ts +193 -0
  40. package/extensions/shared/setup-config.ts +10 -1
  41. package/extensions/shared/structured-output.ts +154 -0
  42. package/extensions/subagents/index.ts +44 -4
  43. package/extensions/subagents/src/backends/pi.ts +76 -5
  44. package/extensions/subagents/src/domain.ts +16 -1
  45. package/extensions/subagents/src/manager.ts +5 -0
  46. package/extensions/subagents/src/prompt.ts +17 -3
  47. package/extensions/subagents/src/result-artifact.ts +32 -0
  48. package/extensions/subagents/src/result-delivery.ts +33 -14
  49. package/extensions/ui-customization/footer.ts +16 -5
  50. package/extensions/user-input-fold/index.ts +42 -6
  51. package/extensions/web/index.ts +25 -2
  52. package/extensions/workflows/acceptance.ts +43 -19
  53. package/extensions/workflows/completion-projection.ts +3 -1
  54. package/extensions/workflows/dashboard.ts +8 -0
  55. package/extensions/workflows/index.ts +13 -0
  56. package/extensions/workflows/model.ts +5 -1
  57. package/extensions/workflows/prompt.ts +4 -10
  58. package/extensions/workflows/result-delivery.ts +96 -22
  59. package/extensions/workflows/retention.ts +6 -0
  60. package/extensions/workflows/runner.ts +6 -71
  61. package/package.json +7 -7
  62. package/skills/subagents/REFERENCE.md +3 -2
  63. package/skills/subagents/SKILL.md +1 -0
  64. package/skills/workflows/REFERENCE.md +3 -3
  65. package/skills/workflows/SKILL.md +1 -1
  66. package/web/adapter/pi-adapter.ts +3 -0
  67. package/web/host/pi-coding-agent-entry.ts +162 -0
  68. package/web/host/web-host.ts +330 -50
  69. package/web/protocol/types.ts +5 -0
  70. package/web/runtime/pi-runtime.ts +240 -25
  71. package/web/runtime/types.ts +32 -1
  72. package/web/ui/app.js +343 -41
  73. package/web/ui/index.html +3 -0
  74. package/web/ui/styles.css +119 -37
@@ -0,0 +1,106 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { basename, isAbsolute } from "node:path";
3
+ import type {
4
+ ExtensionContext,
5
+ InputEvent,
6
+ InputEventResult,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { ImageContent } from "@earendil-works/pi-ai/compat";
9
+
10
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
11
+
12
+ function leadingPath(text: string): { path: string; end: number } | undefined {
13
+ const match = /^\s*(?:"([^"\n]+)"|'([^'\n]+)'|(\S+))/.exec(text);
14
+ if (!match) return undefined;
15
+ const value = match[1] ?? match[2] ?? match[3];
16
+ if (!value || !isAbsolute(value)) return undefined;
17
+ if (!/\.(?:png|jpe?g|gif|webp)$/i.test(value)) return undefined;
18
+ return { path: value, end: match[0].length };
19
+ }
20
+
21
+ function detectImageMimeType(
22
+ bytes: Uint8Array,
23
+ ): ImageContent["mimeType"] | undefined {
24
+ if (
25
+ bytes.length >= 8 &&
26
+ bytes[0] === 0x89 &&
27
+ bytes[1] === 0x50 &&
28
+ bytes[2] === 0x4e &&
29
+ bytes[3] === 0x47 &&
30
+ bytes[4] === 0x0d &&
31
+ bytes[5] === 0x0a &&
32
+ bytes[6] === 0x1a &&
33
+ bytes[7] === 0x0a
34
+ ) {
35
+ return "image/png";
36
+ }
37
+ if (
38
+ bytes.length >= 3 &&
39
+ bytes[0] === 0xff &&
40
+ bytes[1] === 0xd8 &&
41
+ bytes[2] === 0xff
42
+ ) {
43
+ return "image/jpeg";
44
+ }
45
+ if (bytes.length >= 6) {
46
+ const signature = Buffer.from(bytes.subarray(0, 6)).toString("ascii");
47
+ if (signature === "GIF87a" || signature === "GIF89a") return "image/gif";
48
+ }
49
+ if (
50
+ bytes.length >= 12 &&
51
+ Buffer.from(bytes.subarray(0, 4)).toString("ascii") === "RIFF" &&
52
+ Buffer.from(bytes.subarray(8, 12)).toString("ascii") === "WEBP"
53
+ ) {
54
+ return "image/webp";
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * Pi's TUI represents a clipboard image as a leading local path. Cursor's
61
+ * chat-only provider cannot ask a native read-file tool to resolve that path,
62
+ * so convert an explicit leading image path into the same ImageContent shape
63
+ * used by CLI/RPC attachments before the agent turn starts.
64
+ */
65
+ export async function transformCursorImageInput(
66
+ event: InputEvent,
67
+ ctx: ExtensionContext,
68
+ ): Promise<InputEventResult> {
69
+ if (
70
+ event.source !== "interactive" ||
71
+ ctx.model?.provider !== "cursor" ||
72
+ (event.images?.length ?? 0) > 0
73
+ ) {
74
+ return { action: "continue" };
75
+ }
76
+
77
+ const candidate = leadingPath(event.text);
78
+ if (!candidate) return { action: "continue" };
79
+
80
+ try {
81
+ const file = await stat(candidate.path);
82
+ if (!file.isFile() || file.size === 0 || file.size > MAX_IMAGE_BYTES) {
83
+ return { action: "continue" };
84
+ }
85
+ const bytes = await readFile(candidate.path);
86
+ if (bytes.length > MAX_IMAGE_BYTES) return { action: "continue" };
87
+ const mimeType = detectImageMimeType(bytes);
88
+ if (!mimeType) return { action: "continue" };
89
+
90
+ const question = event.text.slice(candidate.end).trimStart();
91
+ const attachment = `Attached image: ${JSON.stringify(basename(candidate.path))}`;
92
+ return {
93
+ action: "transform",
94
+ text: question ? `${attachment}\n${question}` : attachment,
95
+ images: [
96
+ {
97
+ type: "image",
98
+ data: bytes.toString("base64"),
99
+ mimeType,
100
+ },
101
+ ],
102
+ };
103
+ } catch {
104
+ return { action: "continue" };
105
+ }
106
+ }
@@ -0,0 +1,45 @@
1
+ import type { Model } from "@earendil-works/pi-ai/compat";
2
+ import { CURSOR_API_URL } from "./constants.ts";
3
+
4
+ export type CursorModelDefinition = {
5
+ id: string;
6
+ name: string;
7
+ api: "cursor-agent";
8
+ provider: "cursor";
9
+ baseUrl: string;
10
+ reasoning: boolean;
11
+ input: ("text" | "image")[];
12
+ cost: Model<string>["cost"];
13
+ contextWindow: number;
14
+ maxTokens: number;
15
+ cursorMaxMode?: boolean;
16
+ };
17
+
18
+ const ZERO_COST: CursorModelDefinition["cost"] = {
19
+ input: 0,
20
+ output: 0,
21
+ cacheRead: 0,
22
+ cacheWrite: 0,
23
+ };
24
+
25
+ /**
26
+ * Minimal offline catalog. Cursor's usable-model list is account-specific;
27
+ * `default` is the server-side Auto route and remains valid when discovery is
28
+ * unavailable or the account has no models response.
29
+ */
30
+ export const CURSOR_MODELS: CursorModelDefinition[] = [
31
+ {
32
+ id: "default",
33
+ name: "Auto",
34
+ api: "cursor-agent",
35
+ provider: "cursor",
36
+ baseUrl: CURSOR_API_URL,
37
+ reasoning: false,
38
+ input: ["text", "image"],
39
+ cost: ZERO_COST,
40
+ contextWindow: 200_000,
41
+ maxTokens: 64_000,
42
+ },
43
+ ];
44
+
45
+ export const CURSOR_STATIC_MODELS = CURSOR_MODELS;
@@ -0,0 +1,263 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import type {
3
+ OAuthCredentials,
4
+ OAuthLoginCallbacks,
5
+ } from "@earendil-works/pi-ai/compat";
6
+ import type { CursorCredentials } from "./credentials.ts";
7
+
8
+ const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
9
+ const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
10
+ const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key";
11
+
12
+ const POLL_MAX_ATTEMPTS = 150;
13
+ const POLL_BASE_DELAY_MS = 1_000;
14
+ const POLL_MAX_DELAY_MS = 10_000;
15
+ const POLL_BACKOFF_MULTIPLIER = 1.2;
16
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1_000;
17
+ const REQUEST_TIMEOUT_MS = 30 * 1_000;
18
+ const EXPIRY_MARGIN_MS = 5 * 60 * 1_000;
19
+
20
+ export interface CursorAuthParams {
21
+ verifier: string;
22
+ challenge: string;
23
+ uuid: string;
24
+ loginUrl: string;
25
+ }
26
+
27
+ export interface CursorPollOptions {
28
+ /** Test/bridge override; production uses Cursor's auth endpoint. */
29
+ pollUrl?: string;
30
+ maxAttempts?: number;
31
+ baseDelayMs?: number;
32
+ maxDelayMs?: number;
33
+ backoffMultiplier?: number;
34
+ }
35
+
36
+ function throwIfAborted(signal: AbortSignal | undefined): void {
37
+ if (signal?.aborted) {
38
+ throw new Error("Cursor authentication cancelled");
39
+ }
40
+ }
41
+
42
+ function wait(ms: number, signal: AbortSignal | undefined): Promise<void> {
43
+ throwIfAborted(signal);
44
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
45
+ const timer = setTimeout(
46
+ () => {
47
+ signal?.removeEventListener("abort", onAbort);
48
+ resolve();
49
+ },
50
+ Math.max(0, ms),
51
+ );
52
+ const onAbort = () => {
53
+ clearTimeout(timer);
54
+ reject(new Error("Cursor authentication cancelled"));
55
+ };
56
+ signal?.addEventListener("abort", onAbort, { once: true });
57
+ return promise;
58
+ }
59
+
60
+ async function fetchWithTimeout(
61
+ url: string,
62
+ init: RequestInit,
63
+ signal: AbortSignal | undefined,
64
+ ): Promise<Response> {
65
+ throwIfAborted(signal);
66
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
67
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
68
+ try {
69
+ return await fetch(url, { ...init, signal: combined });
70
+ } catch (error) {
71
+ throwIfAborted(signal);
72
+ if (timeout.aborted) {
73
+ throw new Error(`Cursor request timed out after ${REQUEST_TIMEOUT_MS}ms`);
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ export async function generateCursorAuthParams(): Promise<CursorAuthParams> {
80
+ const verifierBytes = randomBytes(96);
81
+ const verifier = verifierBytes.toString("base64url");
82
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
83
+ const uuid = randomUUID();
84
+ const query = new URLSearchParams({
85
+ challenge,
86
+ uuid,
87
+ mode: "login",
88
+ redirectTarget: "cli",
89
+ });
90
+ return {
91
+ verifier,
92
+ challenge,
93
+ uuid,
94
+ loginUrl: `${CURSOR_LOGIN_URL}?${query.toString()}`,
95
+ };
96
+ }
97
+
98
+ /** Poll Cursor's loginDeepControl handoff until the browser finishes. */
99
+ export async function pollCursorAuth(
100
+ uuid: string,
101
+ verifier: string,
102
+ signal?: AbortSignal,
103
+ options?: CursorPollOptions,
104
+ ): Promise<{ accessToken: string; refreshToken: string }> {
105
+ const pollUrl = options?.pollUrl ?? CURSOR_POLL_URL;
106
+ const maxAttempts = options?.maxAttempts ?? POLL_MAX_ATTEMPTS;
107
+ const maxDelay = options?.maxDelayMs ?? POLL_MAX_DELAY_MS;
108
+ const multiplier = options?.backoffMultiplier ?? POLL_BACKOFF_MULTIPLIER;
109
+ let delay = options?.baseDelayMs ?? POLL_BASE_DELAY_MS;
110
+ let consecutiveErrors = 0;
111
+
112
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
113
+ await wait(delay, signal);
114
+ const url = new URL(pollUrl);
115
+ url.searchParams.set("uuid", uuid);
116
+ url.searchParams.set("verifier", verifier);
117
+ try {
118
+ const response = await fetchWithTimeout(url.toString(), {}, signal);
119
+ if (response.status === 404) {
120
+ consecutiveErrors = 0;
121
+ delay = Math.min(delay * multiplier, maxDelay);
122
+ continue;
123
+ }
124
+ if (!response.ok) {
125
+ throw new Error(`Cursor auth poll failed: HTTP ${response.status}`);
126
+ }
127
+ const payload: unknown = await response.json();
128
+ if (!isTokenPayload(payload) || !payload.refreshToken) {
129
+ throw new Error("Cursor auth poll returned an invalid token payload");
130
+ }
131
+ return {
132
+ accessToken: payload.accessToken,
133
+ refreshToken: payload.refreshToken,
134
+ };
135
+ } catch (error) {
136
+ if (signal?.aborted) throw error;
137
+ consecutiveErrors++;
138
+ delay = Math.min(delay * multiplier, maxDelay);
139
+ if (consecutiveErrors >= 3) {
140
+ throw new Error(
141
+ "Too many consecutive errors during Cursor authentication polling",
142
+ );
143
+ }
144
+ }
145
+ }
146
+ throw new Error("Cursor authentication polling timed out");
147
+ }
148
+
149
+ function isTokenPayload(
150
+ value: unknown,
151
+ ): value is { accessToken: string; refreshToken?: string } {
152
+ if (value === null || typeof value !== "object") return false;
153
+ const record = value as Record<string, unknown>;
154
+ return (
155
+ typeof record.accessToken === "string" &&
156
+ record.accessToken.length > 0 &&
157
+ (record.refreshToken === undefined ||
158
+ typeof record.refreshToken === "string")
159
+ );
160
+ }
161
+
162
+ export async function loginCursor(
163
+ callbacks: OAuthLoginCallbacks,
164
+ ): Promise<CursorCredentials> {
165
+ const auth = await generateCursorAuthParams();
166
+ callbacks.onAuth({
167
+ url: auth.loginUrl,
168
+ instructions: "Complete the Cursor sign-in in your browser.",
169
+ });
170
+ callbacks.onProgress?.("Waiting for browser authentication...");
171
+
172
+ const timeout = AbortSignal.timeout(LOGIN_TIMEOUT_MS);
173
+ const signal = callbacks.signal
174
+ ? AbortSignal.any([callbacks.signal, timeout])
175
+ : timeout;
176
+ const tokens = await pollCursorAuth(auth.uuid, auth.verifier, signal);
177
+ return {
178
+ access: tokens.accessToken,
179
+ refresh: tokens.refreshToken,
180
+ expires: getCursorTokenExpiry(tokens.accessToken),
181
+ };
182
+ }
183
+
184
+ export async function refreshCursorToken(
185
+ credentials: OAuthCredentials,
186
+ signal: AbortSignal,
187
+ ): Promise<CursorCredentials> {
188
+ const response = await fetchWithTimeout(
189
+ CURSOR_REFRESH_URL,
190
+ {
191
+ method: "POST",
192
+ headers: {
193
+ Authorization: `Bearer ${credentials.refresh}`,
194
+ "Content-Type": "application/json",
195
+ },
196
+ body: "{}",
197
+ },
198
+ signal,
199
+ );
200
+ if (!response.ok) {
201
+ throw new Error(
202
+ `Cursor token refresh failed: ${response.status} ${await response.text()}`,
203
+ );
204
+ }
205
+ const payload: unknown = await response.json();
206
+ if (!isTokenPayload(payload)) {
207
+ throw new Error("Cursor token refresh returned an invalid token payload");
208
+ }
209
+ return {
210
+ access: payload.accessToken,
211
+ refresh: payload.refreshToken || credentials.refresh,
212
+ expires: getCursorTokenExpiry(payload.accessToken),
213
+ };
214
+ }
215
+
216
+ function decodeCursorJwtPayload(token: string): unknown | undefined {
217
+ const parts = token.split(".");
218
+ if (parts.length !== 3 || !parts[1]) return undefined;
219
+ const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
220
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
221
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
222
+ }
223
+
224
+ /** Returns an expiry with the same five-minute safety margin as the Cursor client. */
225
+ export function getCursorTokenExpiry(token: string): number {
226
+ try {
227
+ const payload = decodeCursorJwtPayload(token);
228
+ if (
229
+ payload !== null &&
230
+ typeof payload === "object" &&
231
+ "exp" in payload &&
232
+ typeof payload.exp === "number" &&
233
+ Number.isFinite(payload.exp)
234
+ ) {
235
+ return payload.exp * 1_000 - EXPIRY_MARGIN_MS;
236
+ }
237
+ } catch {
238
+ // Cursor occasionally returns opaque access tokens; use a conservative hour.
239
+ }
240
+ return Date.now() + 60 * 60 * 1_000;
241
+ }
242
+
243
+ export const getTokenExpiry = getCursorTokenExpiry;
244
+
245
+ export function isCursorTokenExpiringSoon(
246
+ token: string,
247
+ thresholdSeconds = 300,
248
+ ): boolean {
249
+ try {
250
+ const payload = decodeCursorJwtPayload(token);
251
+ if (
252
+ payload === null ||
253
+ typeof payload !== "object" ||
254
+ !("exp" in payload) ||
255
+ typeof payload.exp !== "number"
256
+ ) {
257
+ return true;
258
+ }
259
+ return payload.exp - Math.floor(Date.now() / 1_000) < thresholdSeconds;
260
+ } catch {
261
+ return true;
262
+ }
263
+ }