@tduyng/pi-cursor 0.0.0-stage → 0.0.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.
package/src/session.ts ADDED
@@ -0,0 +1,461 @@
1
+ import type {
2
+ AgentOptions,
3
+ Run,
4
+ RunResult,
5
+ SDKAgent,
6
+ SDKCustomTool,
7
+ SDKCustomToolResult,
8
+ TokenUsage
9
+ } from '@cursor/sdk'
10
+ import type { AssistantMessage, Tool, ToolResultMessage } from '@earendil-works/pi-ai'
11
+ import type { AgentRecord } from './resume.js'
12
+
13
+ import { sha, type JsonObject } from './json.js'
14
+ import { noSystemPrompt, patchState } from './state.js'
15
+ import { selectionFor, type ModelSelection } from './models.js'
16
+ import { cursorApiKey, cursorSdk } from './sdk.js'
17
+ import {
18
+ fingerprint,
19
+ toSdkMessage,
20
+ withPreamble,
21
+ toolResultCustom,
22
+ toolSchema,
23
+ type CursorInput,
24
+ type Transcript
25
+ } from './transcript.js'
26
+
27
+ const MAX_SESSIONS = 6
28
+
29
+ export type SessionKey = {
30
+ systemPrompt: string
31
+ toolsHash: string
32
+ cwd: string
33
+ }
34
+
35
+ export type CursorEvent =
36
+ | { kind: 'text'; text: string }
37
+ | { kind: 'thinking'; text: string }
38
+ | { kind: 'tool'; id: string; name: string; args: JsonObject }
39
+ | { kind: 'grace' }
40
+ | { kind: 'done' }
41
+ | { kind: 'error'; message: string }
42
+
43
+ export type CursorSession = {
44
+ id: string
45
+ key: SessionKey
46
+ systemPrompt: string
47
+ tools: Tool[]
48
+ agent?: SDKAgent
49
+ run?: Run
50
+ queue: CursorEventQueue
51
+ parked: Map<string, (result: SDKCustomToolResult) => void>
52
+ ready: Map<string, SDKCustomToolResult>
53
+ awaiting: Set<string>
54
+ chain: string
55
+ count: number
56
+ usage?: TokenUsage
57
+ reported?: TokenUsage
58
+ toolCounter: number
59
+ promptFallback?: boolean
60
+ promptSent?: boolean
61
+ busy: boolean
62
+ dead: boolean
63
+ lastUsed: number
64
+ abortCleanup?: () => void
65
+ }
66
+
67
+ export type CursorSessionStore = { sessions: Map<string, CursorSession> }
68
+ export type Attached = { session: CursorSession; prefix: number }
69
+
70
+ export class CursorEventQueue {
71
+ private readonly items: CursorEvent[] = []
72
+ private readonly waiters: ((event: CursorEvent | undefined) => void)[] = []
73
+ private closed = false
74
+
75
+ push(event: CursorEvent): void {
76
+ const waiter = this.waiters.shift()
77
+ if (waiter) waiter(event)
78
+ else this.items.push(event)
79
+ }
80
+
81
+ close(): void {
82
+ if (this.closed) return
83
+ this.closed = true
84
+ for (const waiter of this.waiters.splice(0)) waiter(undefined)
85
+ }
86
+
87
+ next(signal?: AbortSignal): Promise<CursorEvent | undefined> {
88
+ const queued = this.items.shift()
89
+ if (queued) return Promise.resolve(queued)
90
+ if (this.closed) return Promise.resolve(undefined)
91
+ return new Promise((resolve, reject) => {
92
+ let settled = false
93
+ const waiter = (event: CursorEvent | undefined) => {
94
+ if (settled) return
95
+ settled = true
96
+ signal?.removeEventListener('abort', onAbort)
97
+ resolve(event)
98
+ }
99
+ const onAbort = () => {
100
+ if (settled) return
101
+ settled = true
102
+ this.waiters.splice(this.waiters.indexOf(waiter), 1)
103
+ reject(new Error('aborted'))
104
+ }
105
+ this.waiters.push(waiter)
106
+ if (signal?.aborted) {
107
+ onAbort()
108
+ return
109
+ }
110
+ signal?.addEventListener('abort', onAbort, { once: true })
111
+ })
112
+ }
113
+ }
114
+
115
+ export function createSessionStore(): CursorSessionStore {
116
+ return { sessions: new Map() }
117
+ }
118
+
119
+ export function allSessions(store: CursorSessionStore): CursorSession[] {
120
+ return [...store.sessions.values()]
121
+ }
122
+
123
+ export function createSession(
124
+ store: CursorSessionStore,
125
+ id: string,
126
+ key: SessionKey,
127
+ systemPrompt: string,
128
+ tools: Tool[],
129
+ transcript: Transcript
130
+ ): CursorSession {
131
+ const session: CursorSession = {
132
+ id,
133
+ key,
134
+ systemPrompt,
135
+ tools,
136
+ queue: new CursorEventQueue(),
137
+ parked: new Map(),
138
+ ready: new Map(),
139
+ awaiting: new Set(),
140
+ chain: transcript.chain[transcript.messages.length] ?? transcript.chain[0] ?? sha('prompt'),
141
+ count: transcript.messages.length,
142
+ toolCounter: 0,
143
+ promptFallback: noSystemPrompt(),
144
+ busy: true,
145
+ dead: false,
146
+ lastUsed: Date.now()
147
+ }
148
+ store.sessions.set(id, session)
149
+ evict(store)
150
+ return session
151
+ }
152
+
153
+ export function attachSession(
154
+ store: CursorSessionStore,
155
+ transcript: Transcript,
156
+ key: SessionKey
157
+ ): Attached | undefined {
158
+ for (const session of store.sessions.values()) {
159
+ if (session.dead || session.busy) continue
160
+ if (!sameKey(session.key, key)) continue
161
+ if (session.count > transcript.messages.length) continue
162
+ if (transcript.chain[session.count] !== session.chain) continue
163
+
164
+ const tail = transcript.messages.slice(session.count)
165
+ if (tail.some((message) => message.role === 'assistant')) continue
166
+
167
+ const results = tail.filter((message) => message.role === 'toolResult')
168
+ const resultIds = new Set(results.map((message) => message.toolCallId))
169
+ if (session.awaiting.size > 0) {
170
+ if (![...session.awaiting].every((id) => resultIds.has(id))) continue
171
+ } else if (resultIds.size > 0) {
172
+ continue
173
+ }
174
+
175
+ session.busy = true
176
+ session.lastUsed = Date.now()
177
+ return { session, prefix: session.count }
178
+ }
179
+ return undefined
180
+ }
181
+
182
+ function sameKey(left: SessionKey, right: SessionKey): boolean {
183
+ return left.systemPrompt === right.systemPrompt && left.toolsHash === right.toolsHash && left.cwd === right.cwd
184
+ }
185
+
186
+ export function updateChain(session: CursorSession, transcript: Transcript, output: AssistantMessage): void {
187
+ const base = transcript.chain[transcript.messages.length] ?? session.chain
188
+ session.chain = sha(`${base}\0${fingerprint(output)}`)
189
+ session.count = transcript.messages.length + 1
190
+ session.lastUsed = Date.now()
191
+ }
192
+
193
+ function agentOptions(session: CursorSession, model: string, systemPrompt: string | undefined): AgentOptions {
194
+ const options: AgentOptions = {
195
+ apiKey: cursorApiKey(),
196
+ model: selectionFor(model, undefined),
197
+ tools: ['mcp'],
198
+ local: {
199
+ cwd: session.key.cwd,
200
+ settingSources: [],
201
+ customTools: customTools(session)
202
+ }
203
+ }
204
+ if (systemPrompt !== undefined) options.systemPrompt = systemPrompt
205
+ return options
206
+ }
207
+
208
+ export async function ensureAgent(session: CursorSession, model: string): Promise<SDKAgent> {
209
+ if (session.agent) return session.agent
210
+ const { Agent } = await cursorSdk()
211
+ const create = (systemPrompt: string | undefined) => Agent.create(agentOptions(session, model, systemPrompt))
212
+ try {
213
+ session.agent = await create(session.promptFallback ? undefined : session.systemPrompt)
214
+ return session.agent
215
+ } catch (error) {
216
+ const message = error instanceof Error ? error.message : String(error)
217
+ if (!session.promptFallback && /system.?prompt/i.test(message)) {
218
+ session.promptFallback = true
219
+ session.agent = await create(undefined)
220
+ patchState({ noSystemPrompt: true })
221
+ return session.agent
222
+ }
223
+ if (/auth|api.?key|login|credential/i.test(message)) {
224
+ throw new Error(`Cursor is not logged in. Run /cursor login, then retry.\n${message}`, { cause: error })
225
+ }
226
+ throw new Error(`Cursor agent could not start: ${message}`, { cause: error })
227
+ }
228
+ }
229
+
230
+ export async function resumeSession(
231
+ store: CursorSessionStore,
232
+ id: string,
233
+ key: SessionKey,
234
+ systemPrompt: string,
235
+ tools: Tool[],
236
+ transcript: Transcript,
237
+ model: string,
238
+ record: AgentRecord
239
+ ): Promise<CursorSession | undefined> {
240
+ const session = createSession(store, id, key, systemPrompt, tools, transcript)
241
+ session.promptFallback = noSystemPrompt()
242
+ session.promptSent = true
243
+ session.count = record.count
244
+ session.chain = record.chain
245
+ try {
246
+ const { Agent } = await cursorSdk()
247
+ const prompt = session.promptFallback ? undefined : systemPrompt
248
+ session.agent = await Agent.resume(record.agentId, agentOptions(session, model, prompt))
249
+ return session
250
+ } catch {
251
+ dropSession(store, session)
252
+ return undefined
253
+ }
254
+ }
255
+
256
+ function customTools(session: CursorSession) {
257
+ const tools: Record<string, SDKCustomTool> = {}
258
+ for (const tool of session.tools) {
259
+ tools[tool.name] = {
260
+ description: tool.description,
261
+ inputSchema: toolSchema(tool),
262
+ execute: (args, context) => {
263
+ const id = context.toolCallId ?? `pi_${++session.toolCounter}`
264
+ const ready = session.ready.get(id)
265
+ if (ready) {
266
+ session.ready.delete(id)
267
+ return ready
268
+ }
269
+ const value: JsonObject = JSON.parse(JSON.stringify(args))
270
+ return new Promise<SDKCustomToolResult>((resolve) => {
271
+ session.parked.set(id, resolve)
272
+ session.queue.push({ kind: 'tool', id, name: tool.name, args: value })
273
+ })
274
+ }
275
+ }
276
+ }
277
+ return tools
278
+ }
279
+
280
+ export async function startRun(session: CursorSession, input: CursorInput, selection: ModelSelection): Promise<void> {
281
+ const agent = await ensureAgent(session, selection.id)
282
+ const payload = session.promptFallback && !session.promptSent ? withPreamble(input, session.systemPrompt) : input
283
+ if (session.promptFallback) session.promptSent = true
284
+ session.usage = undefined
285
+ session.reported = undefined
286
+ let run: Run
287
+ try {
288
+ run = await agent.send(toSdkMessage(payload), {
289
+ model: selection,
290
+ onDelta: ({ update }) => {
291
+ if (update.type === 'text-delta') session.queue.push({ kind: 'text', text: update.text })
292
+ else if (update.type === 'thinking-delta') session.queue.push({ kind: 'thinking', text: update.text })
293
+ else if (update.type === 'turn-ended' && update.usage) {
294
+ session.usage = addUsage(session.usage, normalizeUsage(update.usage))
295
+ }
296
+ }
297
+ })
298
+ } catch (error) {
299
+ const message = error instanceof Error ? error.message : String(error)
300
+ if (!session.promptFallback && /system.?prompt/i.test(message)) {
301
+ await restartWithoutPrompt(session, input, selection)
302
+ return
303
+ }
304
+ throw error
305
+ }
306
+ session.run = run
307
+ void run
308
+ .wait()
309
+ .then(async (result) => {
310
+ if (result.usage) session.usage = largerUsage(session.usage, normalizeUsage(result.usage))
311
+ const outcome = outcomeOf(result, session.promptFallback === true)
312
+ if (outcome.kind === 'retry') {
313
+ await restartWithoutPrompt(session, input, selection)
314
+ return
315
+ }
316
+ if (outcome.kind === 'error') {
317
+ session.queue.push({ kind: 'error', message: outcome.message })
318
+ return
319
+ }
320
+ session.queue.push({ kind: 'done' })
321
+ })
322
+ .catch((error: Error) => {
323
+ session.queue.push({ kind: 'error', message: error.message || String(error) })
324
+ })
325
+ }
326
+
327
+ type ReportedUsage = Omit<TokenUsage, 'totalTokens'>
328
+
329
+ function normalizeUsage(usage: ReportedUsage): TokenUsage {
330
+ return {
331
+ ...usage,
332
+ totalTokens: usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
333
+ }
334
+ }
335
+
336
+ export function addUsage(current: TokenUsage | undefined, next: TokenUsage): TokenUsage {
337
+ if (!current) return { ...next }
338
+ const reasoning = current.reasoningTokens
339
+ const extra = next.reasoningTokens
340
+ const total: TokenUsage = {
341
+ inputTokens: current.inputTokens + next.inputTokens,
342
+ outputTokens: current.outputTokens + next.outputTokens,
343
+ cacheReadTokens: current.cacheReadTokens + next.cacheReadTokens,
344
+ cacheWriteTokens: current.cacheWriteTokens + next.cacheWriteTokens,
345
+ totalTokens: current.totalTokens + next.totalTokens
346
+ }
347
+ if (reasoning !== undefined || extra !== undefined) total.reasoningTokens = (reasoning ?? 0) + (extra ?? 0)
348
+ return total
349
+ }
350
+
351
+ export function largerUsage(current: TokenUsage | undefined, next: TokenUsage): TokenUsage {
352
+ if (!current || next.totalTokens > current.totalTokens) return { ...next }
353
+ return current
354
+ }
355
+
356
+ export function usageDelta(total: TokenUsage | undefined, reported: TokenUsage | undefined): TokenUsage | undefined {
357
+ if (!total) return undefined
358
+ if (!reported) return total
359
+ const delta: TokenUsage = {
360
+ inputTokens: Math.max(total.inputTokens - reported.inputTokens, 0),
361
+ outputTokens: Math.max(total.outputTokens - reported.outputTokens, 0),
362
+ cacheReadTokens: Math.max(total.cacheReadTokens - reported.cacheReadTokens, 0),
363
+ cacheWriteTokens: Math.max(total.cacheWriteTokens - reported.cacheWriteTokens, 0),
364
+ totalTokens: Math.max(total.totalTokens - reported.totalTokens, 0)
365
+ }
366
+ if (total.reasoningTokens !== undefined) {
367
+ delta.reasoningTokens = Math.max(total.reasoningTokens - (reported.reasoningTokens ?? 0), 0)
368
+ }
369
+ return delta
370
+ }
371
+
372
+ export type RunOutcome = { kind: 'done' } | { kind: 'retry' } | { kind: 'error'; message: string }
373
+
374
+ export function outcomeOf(result: RunResult, promptFallback: boolean): RunOutcome {
375
+ if (result.status !== 'error') return { kind: 'done' }
376
+ const message = result.error?.message ?? 'the Cursor run failed'
377
+ if (!promptFallback && /system.?prompt/i.test(message)) return { kind: 'retry' }
378
+ return { kind: 'error', message }
379
+ }
380
+
381
+ async function restartWithoutPrompt(
382
+ session: CursorSession,
383
+ input: CursorInput,
384
+ selection: ModelSelection
385
+ ): Promise<void> {
386
+ session.promptFallback = true
387
+ session.agent?.close()
388
+ session.agent = undefined
389
+ await startRun(session, input, selection)
390
+ patchState({ noSystemPrompt: true })
391
+ }
392
+
393
+ export function deliverToolResult(session: CursorSession, message: ToolResultMessage): void {
394
+ const result = toolResultCustom(message)
395
+ const resolve = session.parked.get(message.toolCallId)
396
+ if (resolve) {
397
+ session.parked.delete(message.toolCallId)
398
+ resolve(result)
399
+ return
400
+ }
401
+ session.ready.set(message.toolCallId, result)
402
+ }
403
+
404
+ export function failMissingResults(session: CursorSession, seen: Set<string>): void {
405
+ for (const id of session.awaiting) {
406
+ if (seen.has(id)) continue
407
+ const result: SDKCustomToolResult = {
408
+ content: [{ type: 'text', text: 'Pi did not return a result for this tool call.' }],
409
+ isError: true
410
+ }
411
+ const resolve = session.parked.get(id)
412
+ if (resolve) {
413
+ session.parked.delete(id)
414
+ resolve(result)
415
+ } else {
416
+ session.ready.set(id, result)
417
+ }
418
+ }
419
+ }
420
+
421
+ export function failParked(session: CursorSession, reason: string): void {
422
+ for (const resolve of session.parked.values()) {
423
+ resolve({ content: [{ type: 'text', text: reason }], isError: true })
424
+ }
425
+ session.parked.clear()
426
+ session.ready.clear()
427
+ }
428
+
429
+ export function dropSession(store: CursorSessionStore, session: CursorSession): void {
430
+ session.dead = true
431
+ for (const [id, candidate] of store.sessions) if (candidate === session) store.sessions.delete(id)
432
+ session.abortCleanup?.()
433
+ session.abortCleanup = undefined
434
+ failParked(session, 'Pi closed this Cursor session.')
435
+ session.queue.close()
436
+ void session.run?.cancel().catch(() => undefined)
437
+ session.agent?.close()
438
+ }
439
+
440
+ export function dropSessions(store: CursorSessionStore): void {
441
+ for (const session of store.sessions.values()) dropSession(store, session)
442
+ }
443
+
444
+ export function sweep(store: CursorSessionStore, idleMs: number): void {
445
+ const now = Date.now()
446
+ for (const session of store.sessions.values()) {
447
+ if (session.busy || session.awaiting.size > 0) continue
448
+ if (now - session.lastUsed > idleMs) dropSession(store, session)
449
+ }
450
+ }
451
+
452
+ function evict(store: CursorSessionStore): void {
453
+ if (store.sessions.size <= MAX_SESSIONS) return
454
+ const idle = [...store.sessions.values()]
455
+ .filter((session) => !session.busy && session.awaiting.size === 0 && session.parked.size === 0)
456
+ .toSorted((left, right) => left.lastUsed - right.lastUsed)
457
+ while (store.sessions.size > MAX_SESSIONS && idle.length > 0) {
458
+ const session = idle.shift()
459
+ if (session) dropSession(store, session)
460
+ }
461
+ }
package/src/state.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
+ import { Validator, type Infer } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ const CursorPreferences = t.object({
9
+ noSystemPrompt: t.optional(t.boolean()),
10
+ fast: t.optional(t.boolean())
11
+ })
12
+
13
+ const isPreferences = new Validator(CursorPreferences).isValidObject
14
+
15
+ export type CursorState = Infer<typeof CursorPreferences>
16
+
17
+ function statePath(): string {
18
+ return join(getAgentDir(), 'pi-cursor.json')
19
+ }
20
+
21
+ export function readState(): CursorState {
22
+ try {
23
+ const parsed: unknown = JSON.parse(readFileSync(statePath(), 'utf8'))
24
+ return isPreferences(parsed) ? parsed : {}
25
+ } catch {
26
+ return {}
27
+ }
28
+ }
29
+
30
+ export function patchState(patch: CursorState): void {
31
+ const path = statePath()
32
+ const temp = `${path}.${process.pid}.tmp`
33
+ writeFileSync(temp, JSON.stringify({ ...readState(), ...patch }), { mode: 0o600 })
34
+ renameSync(temp, path)
35
+ }
36
+
37
+ export function noSystemPrompt(): boolean {
38
+ return readState().noSystemPrompt === true
39
+ }
40
+
41
+ export function fastMode(): boolean {
42
+ return readState().fast === true
43
+ }