@x-otto/plugin-cursor 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugin.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { definePlugin } from '@x-otto/plugin'
2
+
3
+ import { CursorOAuthProvider } from './src/auth/cursor-oauth'
4
+ import { createCursorProvider } from './src/cursor-provider'
5
+
6
+ export default definePlugin((ctx) => ({
7
+ oauthProviderFactories: {
8
+ cursor: () => new CursorOAuthProvider(),
9
+ },
10
+ providerFactories: {
11
+ // 凭据解析统一走宿主 AuthStore(ctx.resolveProviderAuth),对齐 plugin-anthropic/
12
+ // plugin-github-copilot 的既有模式——不在插件内自行解析 auth.json(终局 review 修复)。
13
+ cursor: () => createCursorProvider(() => ctx.resolveProviderAuth('cursor').then((a) => a ?? undefined)),
14
+ },
15
+ }))
@@ -0,0 +1,100 @@
1
+ import type {
2
+ OAuthCredentials,
3
+ OAuthLoginOptions,
4
+ OAuthProvider,
5
+ OAuthRefreshTokenOptions,
6
+ } from '@x-otto/provider'
7
+
8
+ import {
9
+ CURSOR_TOKEN_EXPIRED,
10
+ CursorAuthError,
11
+ cursorNotLoggedIn,
12
+ } from './errors'
13
+ import { normalizeAccessToken, readLocalAuth } from './local-credentials'
14
+
15
+ const CURSOR_API_BASE = process.env['CURSOR_API_BASE'] ?? 'https://api2.cursor.sh'
16
+ const CURSOR_AUTH_CLIENT_ID =
17
+ process.env['CURSOR_AUTH_CLIENT_ID'] ?? 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB'
18
+ const DEFAULT_ACCESS_TTL_MS = 55 * 60 * 1000
19
+
20
+ type RefreshResponse = {
21
+ access_token?: string
22
+ refresh_token?: string
23
+ expires_in?: number
24
+ error?: string
25
+ error_description?: string
26
+ }
27
+
28
+ export class CursorOAuthProvider implements OAuthProvider {
29
+ readonly id = 'cursor'
30
+ readonly name = 'Cursor'
31
+ readonly subscriptionScoped = true
32
+
33
+ async login(options: OAuthLoginOptions): Promise<OAuthCredentials> {
34
+ options.onProgress?.('Reading Cursor credentials from local installation (macOS)...')
35
+ const local = readLocalAuth()
36
+ if (!local.accessToken) {
37
+ throw cursorNotLoggedIn()
38
+ }
39
+ const access = normalizeAccessToken(local.accessToken)
40
+ const refresh = local.refreshToken ? normalizeAccessToken(local.refreshToken) : ''
41
+ options.onProgress?.(
42
+ local.email
43
+ ? `Imported Cursor session for ${local.email}.`
44
+ : 'Imported Cursor session from local storage.',
45
+ )
46
+ return {
47
+ access,
48
+ refresh,
49
+ expires: Date.now() + DEFAULT_ACCESS_TTL_MS,
50
+ machineId: local.machineId ?? undefined,
51
+ }
52
+ }
53
+
54
+ async refreshToken(options: OAuthRefreshTokenOptions): Promise<OAuthCredentials> {
55
+ const refreshToken = options.refresh?.trim()
56
+ if (!refreshToken) {
57
+ throw new CursorAuthError(
58
+ 'Cursor refresh token missing; run `otto auth login cursor` again.',
59
+ CURSOR_TOKEN_EXPIRED,
60
+ )
61
+ }
62
+
63
+ const response = await fetch(`${CURSOR_API_BASE}/oauth/token`, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
66
+ body: JSON.stringify({
67
+ grant_type: 'refresh_token',
68
+ client_id: CURSOR_AUTH_CLIENT_ID,
69
+ refresh_token: refreshToken,
70
+ }),
71
+ })
72
+
73
+ let data: RefreshResponse
74
+ try {
75
+ data = (await response.json()) as RefreshResponse
76
+ } catch {
77
+ throw new CursorAuthError(
78
+ `Cursor token refresh failed: HTTP ${response.status}`,
79
+ CURSOR_TOKEN_EXPIRED,
80
+ )
81
+ }
82
+
83
+ if (!response.ok || !data.access_token) {
84
+ const detail = data.error_description ?? data.error ?? `HTTP ${response.status}`
85
+ throw new CursorAuthError(`Cursor token refresh failed: ${detail}`, CURSOR_TOKEN_EXPIRED)
86
+ }
87
+
88
+ const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 3600
89
+ return {
90
+ access: data.access_token,
91
+ refresh: data.refresh_token ?? refreshToken,
92
+ expires: Date.now() + expiresIn * 1000,
93
+ machineId: typeof options.machineId === 'string' ? options.machineId : undefined,
94
+ }
95
+ }
96
+
97
+ getAccessKey(credentials: OAuthCredentials): string {
98
+ return credentials.access
99
+ }
100
+ }
@@ -0,0 +1,36 @@
1
+ export const CURSOR_NOT_INSTALLED = 'CURSOR_NOT_INSTALLED'
2
+ export const CURSOR_UNSUPPORTED_PLATFORM = 'CURSOR_UNSUPPORTED_PLATFORM'
3
+ export const CURSOR_NOT_LOGGED_IN = 'CURSOR_NOT_LOGGED_IN'
4
+ export const CURSOR_TOKEN_EXPIRED = 'CURSOR_TOKEN_EXPIRED'
5
+ export const CURSOR_TOOLS_NOT_SUPPORTED = 'CURSOR_TOOLS_NOT_SUPPORTED'
6
+
7
+ export class CursorAuthError extends Error {
8
+ readonly code: string
9
+
10
+ constructor(message: string, code: string) {
11
+ super(message)
12
+ this.name = 'CursorAuthError'
13
+ this.code = code
14
+ }
15
+ }
16
+
17
+ export function cursorNotInstalled(path: string): CursorAuthError {
18
+ return new CursorAuthError(
19
+ `Cursor storage not found at ${path}. Install Cursor and sign in first.`,
20
+ CURSOR_NOT_INSTALLED,
21
+ )
22
+ }
23
+
24
+ export function cursorUnsupportedPlatform(platform: string): CursorAuthError {
25
+ return new CursorAuthError(
26
+ `Cursor local credential import is not supported on ${platform} yet (macOS only in M131-1).`,
27
+ CURSOR_UNSUPPORTED_PLATFORM,
28
+ )
29
+ }
30
+
31
+ export function cursorNotLoggedIn(): CursorAuthError {
32
+ return new CursorAuthError(
33
+ 'No Cursor access token in local storage. Open Cursor, sign in, then retry `otto auth login cursor`.',
34
+ CURSOR_NOT_LOGGED_IN,
35
+ )
36
+ }
@@ -0,0 +1,74 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { homedir } from 'node:os'
3
+ import { platform } from 'node:process'
4
+
5
+ import { cursorNotInstalled, cursorUnsupportedPlatform } from './errors'
6
+
7
+ export interface CursorLocalAuth {
8
+ accessToken: string | null
9
+ refreshToken: string | null
10
+ machineId: string | null
11
+ email: string | null
12
+ }
13
+
14
+ export function defaultDbPath(): string {
15
+ if (platform === 'darwin') {
16
+ return `${homedir()}/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
17
+ }
18
+ throw cursorUnsupportedPlatform(platform)
19
+ }
20
+
21
+ function parseStorageRows(output: string): Record<string, string> {
22
+ const out: Record<string, string> = {}
23
+ for (const line of output.split('\n')) {
24
+ if (!line.trim()) continue
25
+ const tab = line.indexOf('|')
26
+ if (tab < 0) continue
27
+ const key = line.slice(0, tab)
28
+ let value = line.slice(tab + 1)
29
+ try {
30
+ const parsed = JSON.parse(value) as unknown
31
+ if (typeof parsed === 'string') {
32
+ value = parsed
33
+ }
34
+ } catch {
35
+ // keep raw string
36
+ }
37
+ out[key] = value
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** Read Cursor auth tokens from macOS SQLite storage (RFC-130 M131-1). */
43
+ export function readLocalAuth(dbPath?: string): CursorLocalAuth {
44
+ const path = dbPath ?? defaultDbPath()
45
+ let output: string
46
+ try {
47
+ output = execFileSync(
48
+ 'sqlite3',
49
+ ['-separator', '|', path, 'SELECT key, value FROM ItemTable'],
50
+ { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
51
+ )
52
+ } catch (err) {
53
+ const code = (err as NodeJS.ErrnoException).code
54
+ if (code === 'ENOENT') {
55
+ throw cursorNotInstalled(path)
56
+ }
57
+ throw err
58
+ }
59
+
60
+ const storage = parseStorageRows(output)
61
+ return {
62
+ accessToken: storage['cursorAuth/accessToken'] ?? null,
63
+ refreshToken: storage['cursorAuth/refreshToken'] ?? null,
64
+ machineId: storage['storage.serviceMachineId'] ?? null,
65
+ email: storage['cursorAuth/cachedEmail'] ?? null,
66
+ }
67
+ }
68
+
69
+ /** Strip `user_01...::` prefix used by some Cursor token formats. */
70
+ export function normalizeAccessToken(token: string): string {
71
+ const trimmed = token.trim()
72
+ const sep = trimmed.indexOf('::')
73
+ return sep >= 0 ? trimmed.slice(sep + 2) : trimmed
74
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * agent-decode.ts —— Cursor connect-rpc agent 响应流的启发式解码器(RFC-130)。
3
+ *
4
+ * **protobuf 字段来源说明**(终局 review endgame-2026-07-23 补齐):Cursor 的 agent 响应协议
5
+ * 未公开 .proto schema,本文件对 wire 格式的理解全部来自对 cursor-agent CLI 网络抓包的逆向
6
+ * 工程,非官方文档。关键假设:
7
+ * - `decodeMessage()` 是标准 protobuf wire format 通用解码(varint tag/length-delimited),
8
+ * 这部分是协议无关的通用规则,不依赖具体 schema。
9
+ * - `extractTextChunks()` 里 `fieldNum >= 1 && fieldNum <= 10` 是启发式范围,非已知的
10
+ * "text 字段固定编号"——含义是"响应体顶层消息的前 10 个 length-delimited 字段都有可能
11
+ * 携带 prose 文本,逐个尝试 UTF-8 解码 + `isPlausibleAssistantText()` 过滤噪音"。这不是
12
+ * "字段 3 是标题、字段 7 是正文"这类精确 schema 知识,而是"广撒网 + 启发式过滤"策略。
13
+ * - `parseConnectStream()` 的 5 字节 length-prefix + flag(1=gzip / 2=plain text / 3=gzip+text)
14
+ * 是 Connect-RPC streaming 协议的标准 framing(非 Cursor 私有),可信度高于上面的字段编号猜测。
15
+ * - 若未来 Cursor 升级协议导致解码失效(如新增字段类型、文本编码变化),应重新抓包核实,
16
+ * 而非假设本文件的字段编号有官方依据。
17
+ */
18
+ import { gunzipSync } from 'node:zlib'
19
+
20
+ const TEXT_PART_RE = /"type":"text","text":"((?:\\.|[^"\\])*)"/g
21
+
22
+ function unescapeJsonString(raw: string): string {
23
+ return JSON.parse(`"${raw}"`) as string
24
+ }
25
+
26
+ export function extractAgentTextParts(raw: Uint8Array, userPrompt: string): string[] {
27
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(raw)
28
+ const promptLower = userPrompt.toLowerCase().trim()
29
+ const parts: string[] = []
30
+ for (const match of text.matchAll(TEXT_PART_RE)) {
31
+ const value = unescapeJsonString(match[1] ?? '')
32
+ const trimmed = value.trim()
33
+ if (!trimmed || trimmed.length > 4000) continue
34
+ // 仅过滤与用户输入完全相同的回声,不用 includes(短 prompt 如 "hi" 会误杀 "Hi! ...")
35
+ if (promptLower && trimmed.toLowerCase() === promptLower) continue
36
+ if (trimmed.startsWith('<') || trimmed.includes('user_query')) continue
37
+ parts.push(trimmed)
38
+ }
39
+ return parts
40
+ }
41
+
42
+ export function pickAssistantText(parts: string[]): string {
43
+ const usable = parts.filter((p) => isPlausibleAssistantText(p) && looksLikeProse(p))
44
+ const pool = usable.length > 0 ? usable : parts
45
+ const pong = pool.find((p) => p.toLowerCase() === 'pong')
46
+ if (pong) return pong
47
+ const short = pool.filter((p) => p.length <= 200).sort((a, b) => a.length - b.length)
48
+ return short[0] ?? pool[pool.length - 1] ?? ''
49
+ }
50
+
51
+ function decodeVarint(data: Uint8Array, pos: number): [number, number] {
52
+ let result = 0
53
+ let shift = 0
54
+ while (pos < data.length) {
55
+ const byte = data[pos]!
56
+ result |= (byte & 0x7f) << shift
57
+ pos += 1
58
+ if ((byte & 0x80) === 0) break
59
+ shift += 7
60
+ }
61
+ return [result, pos]
62
+ }
63
+
64
+ function decodeMessage(data: Uint8Array): Map<number, Array<{ wireType: number; value: Uint8Array | number }>> {
65
+ const fields = new Map<number, Array<{ wireType: number; value: Uint8Array | number }>>()
66
+ let pos = 0
67
+ while (pos < data.length) {
68
+ const [tag, nextPos] = decodeVarint(data, pos)
69
+ if (nextPos === pos) break
70
+ pos = nextPos
71
+ const fieldNum = tag >> 3
72
+ const wireType = tag & 0x07
73
+ let value: Uint8Array | number
74
+ if (wireType === 0) {
75
+ ;[value, pos] = decodeVarint(data, pos)
76
+ } else if (wireType === 2) {
77
+ const [length, lengthPos] = decodeVarint(data, pos)
78
+ pos = lengthPos
79
+ value = data.slice(pos, pos + length)
80
+ pos += length
81
+ } else {
82
+ break
83
+ }
84
+ const bucket = fields.get(fieldNum) ?? []
85
+ bucket.push({ wireType, value })
86
+ fields.set(fieldNum, bucket)
87
+ }
88
+ return fields
89
+ }
90
+
91
+ /** Opaque base64/base64url/hex blobs that appear as protobuf string fields but are not prose. */
92
+ function isOpaqueToken(text: string): boolean {
93
+ const trimmed = text.trim()
94
+ if (/^[0-9a-f-]{16,}$/i.test(trimmed)) return true
95
+ // Cursor 3.11+ embeds long base64url checksum/session blobs (≥40 chars, no whitespace)
96
+ if (/^[A-Za-z0-9+/_-]{40,}={0,2}$/.test(trimmed)) return true
97
+ return false
98
+ }
99
+
100
+ function looksLikeProse(text: string): boolean {
101
+ if (/[\u4e00-\u9fff]/.test(text)) return true
102
+ if (/\s/.test(text) && /\p{L}{2,}/u.test(text)) return true
103
+ if (/[.!?。!?*]/.test(text) && /\p{L}/u.test(text)) return true
104
+ return false
105
+ }
106
+
107
+ function isPlausibleAssistantText(text: string): boolean {
108
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text)) return false
109
+ const trimmed = text.trim()
110
+ if (!trimmed || !/\p{L}/u.test(trimmed)) return false
111
+ if (isOpaqueToken(trimmed)) return false
112
+ // Long alphanumeric-only strings without prose signals are tokens, not answers
113
+ if (trimmed.length >= 40 && !looksLikeProse(trimmed)) return false
114
+ return true
115
+ }
116
+
117
+ function extractTextChunks(data: Uint8Array): string[] {
118
+ const chunks: string[] = []
119
+ const stack: Uint8Array[] = [data]
120
+ while (stack.length > 0) {
121
+ const chunk = stack.pop()!
122
+ let fields: Map<number, Array<{ wireType: number; value: Uint8Array | number }>>
123
+ try {
124
+ fields = decodeMessage(chunk)
125
+ } catch {
126
+ continue
127
+ }
128
+ for (const [fieldNum, values] of fields) {
129
+ for (const { wireType, value } of values) {
130
+ if (wireType !== 2 || !(value instanceof Uint8Array)) continue
131
+ if (fieldNum >= 1 && fieldNum <= 10) {
132
+ if (value.length <= 1 || value.length >= 8000) continue
133
+ try {
134
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(value)
135
+ const stripped = text.trim()
136
+ if (!stripped) continue
137
+ if (stripped.startsWith('cursor') || stripped.startsWith('{')) continue
138
+ if (stripped.length >= 2 && /\p{L}/u.test(stripped) && isPlausibleAssistantText(text)) {
139
+ chunks.push(text)
140
+ }
141
+ } catch {
142
+ stack.push(value)
143
+ }
144
+ } else if (value.length > 8) {
145
+ stack.push(value)
146
+ }
147
+ }
148
+ }
149
+ }
150
+ return chunks
151
+ }
152
+
153
+ function parseConnectStream(raw: Uint8Array): string[] {
154
+ const texts: string[] = []
155
+ let pos = 0
156
+ while (pos + 5 <= raw.length) {
157
+ const flag = raw[pos]!
158
+ const length =
159
+ (raw[pos + 1]! << 24) |
160
+ (raw[pos + 2]! << 16) |
161
+ (raw[pos + 3]! << 8) |
162
+ raw[pos + 4]!
163
+ pos += 5
164
+ if (length === 0 || pos + length > raw.length) break
165
+ let payload = raw.slice(pos, pos + length)
166
+ pos += length
167
+
168
+ if (flag === 1) {
169
+ try {
170
+ payload = gunzipSync(payload)
171
+ } catch {
172
+ continue
173
+ }
174
+ } else if (flag === 2) {
175
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(payload).trim()
176
+ if (text && isPlausibleAssistantText(text)) texts.push(text)
177
+ continue
178
+ } else if (flag === 3) {
179
+ try {
180
+ payload = gunzipSync(payload)
181
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(payload).trim()
182
+ if (text && isPlausibleAssistantText(text)) texts.push(text)
183
+ } catch {
184
+ // ignore
185
+ }
186
+ continue
187
+ } else if (flag !== 0) {
188
+ continue
189
+ }
190
+
191
+ texts.push(...extractTextChunks(payload))
192
+ }
193
+
194
+ const seen = new Set<string>()
195
+ const out: string[] = []
196
+ for (const text of texts) {
197
+ if (seen.has(text)) continue
198
+ seen.add(text)
199
+ out.push(text)
200
+ }
201
+ return out
202
+ }
203
+
204
+ function scoreAssistantCandidate(text: string, userPrompt: string, frameIdx: number): number {
205
+ const trimmed = text.trim()
206
+ if (!isPlausibleAssistantText(trimmed)) return Number.NEGATIVE_INFINITY
207
+ const lower = trimmed.toLowerCase()
208
+ const promptLower = userPrompt.toLowerCase().trim()
209
+ const promptWords = promptLower.split(/\s+/).filter((w) => w.length > 3)
210
+
211
+ if (trimmed.length < 2 || trimmed.length > 4000) return Number.NEGATIVE_INFINITY
212
+ if (lower === promptLower || (promptLower && lower.includes(promptLower))) {
213
+ return Number.NEGATIVE_INFINITY
214
+ }
215
+ if (lower.includes('you are an ai coding assistant') || lower.includes('"role":"system"')) {
216
+ return Number.NEGATIVE_INFINITY
217
+ }
218
+ if (isOpaqueToken(trimmed)) return Number.NEGATIVE_INFINITY
219
+ if (lower.includes('update required') || lower.includes('"error"')) {
220
+ return Number.NEGATIVE_INFINITY
221
+ }
222
+ if (trimmed === 'default' || trimmed === 'Ask' || trimmed === 'Agent' || trimmed === 'otto-spike') {
223
+ return Number.NEGATIVE_INFINITY
224
+ }
225
+ if (trimmed === '/context.txt' || trimmed === 'otto') return Number.NEGATIVE_INFINITY
226
+ // Prefer complete prose over streamed single-token deltas / metadata
227
+ if (!looksLikeProse(trimmed)) return Number.NEGATIVE_INFINITY
228
+
229
+ let score = Math.min(trimmed.length, 200) + frameIdx * 10
230
+ if (/[\u4e00-\u9fff]/.test(trimmed)) score += 80
231
+ if (/[.!?。!?]$/.test(trimmed)) score += 30
232
+ if (trimmed.includes(' ')) score += 25
233
+ if (/\*\*/.test(trimmed)) score += 10
234
+ // Thinking/scratch lines often echo the user ask — demote heavily
235
+ if (lower.startsWith('the user is asking') || lower.startsWith('用户要求')) score -= 150
236
+ if (promptWords.length > 0) {
237
+ const matches = promptWords.filter((w) => lower.includes(w)).length
238
+ if (matches / promptWords.length > 0.5) score -= 200
239
+ }
240
+ return score
241
+ }
242
+
243
+ function cleanProse(text: string): string {
244
+ // Drop leading protobuf framing leftovers (e.g. ".\n,今天是…")
245
+ const match = text.match(/\p{L}[\s\S]*/u)
246
+ return (match?.[0] ?? text).trim()
247
+ }
248
+
249
+ function extractFromConnectStream(raw: Uint8Array, userPrompt: string): string {
250
+ const candidates: Array<{ score: number; text: string }> = []
251
+ const streamTexts = parseConnectStream(raw)
252
+ for (const [frameIdx, text] of streamTexts.entries()) {
253
+ const cleaned = cleanProse(text)
254
+ const score = scoreAssistantCandidate(cleaned, userPrompt, frameIdx)
255
+ if (Number.isFinite(score)) {
256
+ candidates.push({ score, text: cleaned })
257
+ }
258
+ }
259
+
260
+ if (candidates.length === 0) return ''
261
+ candidates.sort((a, b) => b.score - a.score)
262
+ return candidates[0]?.text ?? ''
263
+ }
264
+
265
+ /** Extract assistant text from AgentService/Run response (JSON fragments + protobuf fallback). */
266
+ export function extractAgentText(raw: Uint8Array, userPrompt: string): string {
267
+ const fromJson = pickAssistantText(extractAgentTextParts(raw, userPrompt))
268
+ if (fromJson.trim()) return fromJson
269
+ return extractFromConnectStream(raw, userPrompt)
270
+ }
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { concatBytes, encodeField } from './protobuf'
4
+
5
+ export function encodeAgentRunRequest(prompt: string, model: string): Uint8Array {
6
+ const messageId = randomUUID()
7
+ const conversationId = randomUUID()
8
+
9
+ const userMsg = concatBytes([
10
+ encodeField(1, 2, prompt),
11
+ encodeField(2, 2, messageId),
12
+ encodeField(3, 2, ''),
13
+ ])
14
+
15
+ const fileCtx = concatBytes([
16
+ encodeField(1, 2, '/context.txt'),
17
+ encodeField(2, 2, 'otto'),
18
+ ])
19
+
20
+ const explicitCtx = encodeField(2, 2, fileCtx)
21
+ const userAction = concatBytes([encodeField(1, 2, userMsg), encodeField(2, 2, explicitCtx)])
22
+ const convAction = encodeField(1, 2, userAction)
23
+
24
+ const display = model.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
25
+ const modelDetails = concatBytes([
26
+ encodeField(1, 2, model),
27
+ encodeField(3, 2, model),
28
+ encodeField(4, 2, display),
29
+ encodeField(5, 2, display),
30
+ encodeField(7, 0, 0),
31
+ ])
32
+
33
+ const runReq = concatBytes([
34
+ encodeField(1, 2, ''),
35
+ encodeField(2, 2, convAction),
36
+ encodeField(3, 2, modelDetails),
37
+ encodeField(4, 2, ''),
38
+ encodeField(5, 2, conversationId),
39
+ ])
40
+
41
+ return encodeField(1, 2, runReq)
42
+ }
43
+
44
+ export function encodeAgentConnectBody(prompt: string, model: string): Uint8Array {
45
+ const payload = encodeAgentRunRequest(prompt, model)
46
+ const lengthHex = payload.length.toString(16).padStart(8, '0')
47
+ const lengthBytes = Uint8Array.from(Buffer.from(lengthHex, 'hex'))
48
+ const out = new Uint8Array(1 + lengthBytes.length + payload.length)
49
+ out[0] = 0
50
+ out.set(lengthBytes, 1)
51
+ out.set(payload, 5)
52
+ return out
53
+ }
@@ -0,0 +1,101 @@
1
+ import http2 from 'node:http2'
2
+
3
+ import { encodeAgentConnectBody } from './agent-request'
4
+ import { buildAgentHeaders, CURSOR_AGENT_BACKEND } from './headers'
5
+
6
+ export interface AgentStreamOptions {
7
+ token: string
8
+ machineId: string | null
9
+ prompt: string
10
+ model: string
11
+ signal?: AbortSignal
12
+ idleMs?: number
13
+ hardMs?: number
14
+ }
15
+
16
+ export async function collectAgentResponse(options: AgentStreamOptions): Promise<Uint8Array> {
17
+ const {
18
+ token,
19
+ machineId,
20
+ prompt,
21
+ model,
22
+ signal,
23
+ idleMs = 3000,
24
+ hardMs = 60000,
25
+ } = options
26
+
27
+ const body = encodeAgentConnectBody(prompt, model)
28
+ const headers = buildAgentHeaders(token, machineId)
29
+ const host = new URL(CURSOR_AGENT_BACKEND).host
30
+
31
+ return new Promise((resolve, reject) => {
32
+ const chunks: Buffer[] = []
33
+ let lastDataAt = Date.now()
34
+ const startedAt = Date.now()
35
+ let settled = false
36
+
37
+ const client = http2.connect(CURSOR_AGENT_BACKEND)
38
+ const finish = (err?: Error, data?: Buffer) => {
39
+ if (settled) return
40
+ settled = true
41
+ clearInterval(idleTimer)
42
+ client.close()
43
+ if (err) reject(err)
44
+ else resolve(new Uint8Array(data ?? Buffer.concat(chunks)))
45
+ }
46
+
47
+ const idleTimer = setInterval(() => {
48
+ if (signal?.aborted) {
49
+ finish(new Error('Aborted'))
50
+ return
51
+ }
52
+ if (chunks.length > 0 && Date.now() - lastDataAt >= idleMs) {
53
+ finish(undefined, Buffer.concat(chunks))
54
+ }
55
+ if (Date.now() - startedAt >= hardMs) {
56
+ finish(undefined, Buffer.concat(chunks))
57
+ }
58
+ }, 250)
59
+
60
+ client.on('error', (err) => finish(err))
61
+
62
+ const req = client.request({
63
+ ':method': 'POST',
64
+ ':path': '/agent.v1.AgentService/Run',
65
+ ':authority': host,
66
+ ...headers,
67
+ })
68
+
69
+ req.on('response', (resHeaders) => {
70
+ const status = Number(resHeaders[':status'] ?? 0)
71
+ if (status !== 200) {
72
+ const errChunks: Buffer[] = []
73
+ req.on('data', (c) => errChunks.push(Buffer.from(c)))
74
+ req.on('end', () => {
75
+ finish(
76
+ new Error(
77
+ `Cursor agent HTTP ${status}: ${Buffer.concat(errChunks).toString('utf8').slice(0, 400)}`,
78
+ ),
79
+ )
80
+ })
81
+ }
82
+ })
83
+
84
+ req.on('data', (chunk) => {
85
+ chunks.push(Buffer.from(chunk))
86
+ lastDataAt = Date.now()
87
+ })
88
+
89
+ req.on('end', () => finish(undefined, Buffer.concat(chunks)))
90
+ req.on('error', (err) => finish(err))
91
+
92
+ signal?.addEventListener(
93
+ 'abort',
94
+ () => finish(new Error('Aborted')),
95
+ { once: true },
96
+ )
97
+
98
+ req.write(body)
99
+ req.end()
100
+ })
101
+ }