@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/LICENSE +178 -0
- package/README.md +104 -2
- package/package.json +42 -4
- package/src/catalog.ts +69 -0
- package/src/index.ts +195 -0
- package/src/json.ts +74 -0
- package/src/models.ts +198 -0
- package/src/resume.ts +81 -0
- package/src/sdk.ts +14 -0
- package/src/session.ts +461 -0
- package/src/state.ts +43 -0
- package/src/transcript.ts +210 -0
- package/src/turn.ts +389 -0
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
|
+
}
|