dsh-cursor-dev 0.0.0-stage → 0.11.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.
@@ -0,0 +1,927 @@
1
+ /**
2
+ * Pool of live Cursor sessions + the global turn lock.
3
+ *
4
+ * This is what makes "接着上次继续改" real, on two levels:
5
+ *
6
+ * 1. LIVE — a pooled session keeps its Cursor process (and therefore its
7
+ * conversation context) alive between tool calls, so a follow-up call is a
8
+ * genuine continuation rather than a fresh, amnesiac run.
9
+ * 2. DURABLE — if the process is gone (DSH restarted, the pool evicted it, the
10
+ * OS reaped it), the Cursor session id is rehydrated with `session/load`, so
11
+ * the child still remembers what it did. Only a session that never completed
12
+ * a prompt is unresumable, because the CLI has not flushed it to disk yet.
13
+ *
14
+ * THE GLOBAL MODEL LOCK (important, non-obvious):
15
+ * the Cursor CLI applies `session/set_model` GLOBALLY — switching the model for
16
+ * one conversation changes what every session in every process reports, and the
17
+ * choice survives restarts. Two conversations running concurrently with
18
+ * different models would therefore corrupt each other. `run()` takes an
19
+ * exclusive turn lock, so exactly one Cursor turn is ever in flight inside this
20
+ * plugin, and the model is set immediately before the prompt that needs it.
21
+ * A *conversation* (`parent::label`) can have several *generations*
22
+ * (`parent::label#N`). A turn that continues the conversation reuses the current
23
+ * generation; a turn that must start fresh bumps the generation, so the fresh
24
+ * run cannot accidentally inherit the old conversation's context.
25
+ *
26
+ * Trade-offs are explicit:
27
+ * - each live session costs one `cursor-agent` process, so the pool is capped
28
+ * and evicts least-recently-used entries (evicting is now cheap: the next
29
+ * turn reloads the conversation instead of losing it);
30
+ * - idle sessions are reaped after `idleMs`;
31
+ * - a session bound to a different working directory is replaced.
32
+ */
33
+ import { CursorSession, CursorAcpError } from './cursor-session.js';
34
+ import { listRosterModels, resolveModel, rosterFromSession, sameModel } from './models.js';
35
+
36
+ /** How many live Cursor processes the pool keeps at once. */
37
+ const DEFAULT_MAX_LIVE = 3;
38
+ /** How long an unused session is kept alive before it is reaped. */
39
+ const DEFAULT_IDLE_MS = 15 * 60 * 1000;
40
+ /** Budget for the throwaway roster probe, which must not block the panel. */
41
+ const PROBE_TIMEOUT_MS = 30 * 1000;
42
+
43
+ /**
44
+ * Owns every live {@link CursorSession}, the global turn lock, and the durable
45
+ * session-id map that lets a dead conversation come back.
46
+ */
47
+ export class SessionPool {
48
+ #config;
49
+ #sessions = new Map();
50
+ #generations = new Map();
51
+ #persisted = new Map();
52
+ #maxLive;
53
+ #idleMs;
54
+ #reaper;
55
+ #onEvent;
56
+ #disposed = false;
57
+ /** Tail of the FIFO queue that serializes every Cursor turn. */
58
+ #gate = Promise.resolve();
59
+ #lockDepth = 0;
60
+
61
+ /**
62
+ * @param config - plugin config (maxLiveSessions, sessionIdleMinutes, spawn/env).
63
+ * @param onEvent - event sink shared by every session.
64
+ */
65
+ constructor(config, onEvent) {
66
+ this.#config = config;
67
+ this.#onEvent = onEvent;
68
+ this.#maxLive = config.maxLiveSessions ?? DEFAULT_MAX_LIVE;
69
+ this.#idleMs = (config.sessionIdleMinutes ?? DEFAULT_IDLE_MS / 60000) * 60000;
70
+ }
71
+
72
+ /** Start the idle reaper. */
73
+ start() {
74
+ if (this.#reaper !== undefined)
75
+ return;
76
+ const interval = Math.max(30_000, Math.min(this.#idleMs, 120_000));
77
+ this.#reaper = setInterval(() => {
78
+ this.reapIdle();
79
+ }, interval);
80
+ this.#reaper?.unref?.();
81
+ }
82
+
83
+ /** Keys of every currently live session. */
84
+ liveKeys() {
85
+ return [...this.#sessions.keys()];
86
+ }
87
+
88
+ /** Snapshot metadata for every live session (used by the status route). */
89
+ snapshot() {
90
+ return [...this.#sessions.values()].map(entry => ({
91
+ key: entry.key,
92
+ cursorSessionId: entry.session.sessionId,
93
+ cwd: entry.session.cwd,
94
+ model: entry.session.models?.currentModelId,
95
+ // What this pool last APPLIED, in the caller's own name space
96
+ // (`default[]`, `auto`, …) — not the roster's spelling. The status
97
+ // route and the tool's change detection both need the request that
98
+ // is in effect, which the id alone does not say.
99
+ modelChoice: entry.modelChoice,
100
+ mode: entry.session.modes?.currentModeId,
101
+ turns: entry.turns.length,
102
+ reloaded: entry.reloaded,
103
+ idleMs: Date.now() - entry.lastUsed,
104
+ alive: entry.session.alive,
105
+ }));
106
+ }
107
+
108
+ /** Every Cursor session id this plugin knows about, live or dormant. */
109
+ persistedSessions() {
110
+ return [...this.#persisted.entries()].map(([storeKey, record]) => ({
111
+ storeKey,
112
+ sessionId: record.sessionId,
113
+ cwd: record.cwd,
114
+ model: record.model,
115
+ modelChoice: record.modelChoice,
116
+ }));
117
+ }
118
+
119
+ /**
120
+ * Resolve a model choice for `key` without acquiring the turn lock.
121
+ *
122
+ * Resolution itself needs no live process when the pool already knows a
123
+ * roster: a session switched earlier in this run publishes its roster, and
124
+ * the CLI's model state is global, so that roster lists every id the next
125
+ * `set_config_option` will accept. A caller needs this BEFORE `run()`
126
+ * because the outcome decides whether the turn may continue the current
127
+ * conversation generation at all (a real model change must not inherit live
128
+ * context, an alias-to-variant switch must not start a new conversation).
129
+ * With nothing known the normalized choice comes back unchanged and the
130
+ * pool decides again under the lock, where a fresh session publishes its
131
+ * own roster.
132
+ *
133
+ * The roster consulted is scoped to THIS key when the pool has one. Model
134
+ * state is process-global, so any roster is usable for the resolution
135
+ * itself — but the model in effect belongs to the conversation, and grading
136
+ * a request against another conversation's choice would make a genuine
137
+ * switch look like a continuation (or the reverse).
138
+ *
139
+ * @param key - pool key whose known roster should be consulted.
140
+ * @param choice - the requested model (`'auto'`, an alias, or a full id), or
141
+ * `undefined` to ask what the conversation is running instead.
142
+ * @returns the pool's resolution result (`{id, ok, exact, aliased, error?}`).
143
+ */
144
+ resolveModelChoice(key, choice) {
145
+ // Two call styles reach here and both are meaningful. With a `choice`,
146
+ // grade that request against the roster this pool has scoped to `key`.
147
+ // Without one — the argument is a conversation key and the question is
148
+ // "what does it run?" — answer with the model in effect, never the
149
+ // empty-string pseudo-answer `resolveModel(undefined, …)` returns, which
150
+ // reads as "unspecified" and hides a genuine mismatch.
151
+ if (choice === undefined)
152
+ return this.resolveCurrentModel(storeKeyOf(String(key))) ?? resolveModel('', this.#knownRoster(key));
153
+ return resolveModel(choice, this.#knownRoster(key));
154
+ }
155
+
156
+ /**
157
+ * What the pool knows about the model in effect for one conversation.
158
+ *
159
+ * Two questions have to be answered separately and both live here, because
160
+ * the pool holds two name spaces for one model: the request a caller wrote
161
+ * (`'auto'`, `'composer-2.5-fast'`) and the id the CLI reports
162
+ * (`'default[]'`, `'composer-2.5[fast=true]'`). Either spelling alone makes
163
+ * a continuation look like a switch.
164
+ *
165
+ * - `choice` is the APPLIED choice, in the caller's own name space.
166
+ * - `id` is that model resolved against the roster — i.e. the id the
167
+ * conversation is actually running — or `undefined` when the roster (and
168
+ * therefore any id) is unknown, which is exactly the `undefined` the
169
+ * resolution must return rather than a made-up match.
170
+ *
171
+ * Only this conversation's own records are consulted — never another
172
+ * conversation's roster, which is reachable through the global CLI state
173
+ * but says nothing about what this conversation runs.
174
+ *
175
+ * `request` is the incoming model choice resolved against that SAME roster
176
+ * with `resolveModel` — a second name space a raw string comparison cannot
177
+ * bridge. The CLI publishes aliases such as `composer-2.5-fast` while the
178
+ * roster ids are parameterised (`composer-2.5[fast=true]`), so
179
+ * `sameModel(currentModel, pickedModel)` is false for a request that names
180
+ * the running model, while the pool itself sends no `set_config_option` for
181
+ * it. Comparing `id` with `request.id` is what recognises that pair; this is
182
+ * deliberately NOT folded into `sameModel`, which keeps its meaning as "same
183
+ * model said two ways" only for spellings it can already compare.
184
+ *
185
+ * @param storeKey - conversation-scoped durable key.
186
+ * @param request - optional incoming model choice to resolve alongside.
187
+ * @returns `{choice?, id?, request?, record?}`, all-empty when the pool knows
188
+ * nothing.
189
+ */
190
+ modelContext(storeKey, request) {
191
+ const live = [...this.#sessions.values()]
192
+ .find(entry => storeKeyOf(entry.key) === storeKey);
193
+ const record = live ?? this.#persisted.get(storeKey);
194
+ if (record === undefined)
195
+ return {};
196
+ const roster = rosterOf(record);
197
+ const choice = record.modelChoice ?? record.model;
198
+ const id = choice === undefined || choice === '' ? undefined : resolveModel(choice, roster).id;
199
+ return {
200
+ ...(choice === undefined ? {} : { choice }),
201
+ ...(id === undefined ? {} : { id }),
202
+ ...(request === undefined ? {} : { request: resolveModel(request, roster) }),
203
+ record,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * The model choice currently in effect for a conversation.
209
+ *
210
+ * The live session wins (its roster is the truth for what a switch would
211
+ * accept right now); otherwise the durable record of the last run answers.
212
+ * `undefined` means no model was ever applied, i.e. the CLI default.
213
+ *
214
+ * @param storeKey - conversation-scoped durable key.
215
+ */
216
+ currentModelChoice(storeKey) {
217
+ for (const entry of this.#sessions.values()) {
218
+ if (storeKeyOf(entry.key) === storeKey && entry.modelChoice !== undefined)
219
+ return entry.modelChoice;
220
+ }
221
+ return this.#persisted.get(storeKey)?.modelChoice;
222
+ }
223
+
224
+ /**
225
+ * The pool's own view of the model in effect for one conversation, resolved
226
+ * through the roster rather than compared as a raw string.
227
+ *
228
+ * The result's `id` is the CURRENT model — never the incoming request —
229
+ * because the caller's job is to compare its own request against the model
230
+ * the conversation runs with `sameModel`. Resolving the request instead
231
+ * would make it always equal itself, so a genuine switch would look like a
232
+ * continuation and inherit live context. The optional second argument
233
+ * resolves a candidate request against the same roster and returns it as
234
+ * `choice`, for a caller that wants both sides in one roster.
235
+ *
236
+ * Only this conversation's own records are consulted — never another
237
+ * conversation's roster, which is reachable through the global CLI state but
238
+ * says nothing about what this conversation runs.
239
+ *
240
+ * @param storeKey - conversation-scoped durable key.
241
+ * @param choice - optional candidate request to resolve alongside.
242
+ * @returns `{id, ok, exact, aliased, choice?}`, or `undefined` when the pool
243
+ * knows nothing yet.
244
+ */
245
+ resolveCurrentModel(storeKey, choice) {
246
+ const record = this.#sessions.values().find(entry => storeKeyOf(entry.key) === storeKey)
247
+ ?? this.#persisted.get(storeKey);
248
+ if (record?.modelChoice === undefined)
249
+ return undefined;
250
+ const roster = rosterOf(record);
251
+ // The resolution of the CURRENT model is the load-bearing part: it is
252
+ // what the caller compares its own request against with `sameModel`.
253
+ const resolved = resolveModel(record.modelChoice, roster);
254
+ return {
255
+ id: resolved.id,
256
+ ok: resolved.ok,
257
+ exact: resolved.exact,
258
+ aliased: resolved.aliased,
259
+ ...(choice === undefined ? {} : { choice: resolveModel(choice, roster) }),
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Any roster this pool has already seen, newest usage first.
265
+ *
266
+ * Used only to answer a question BEFORE a session exists; the authoritative
267
+ * roster of a turn always comes from the session that turn just acquired.
268
+ *
269
+ * @param key - pool key to prefer.
270
+ */
271
+ #knownRoster(key) {
272
+ const preferred = this.#sessions.get(key);
273
+ for (const entry of [preferred, ...this.#sessions.values()]) {
274
+ if (entry === undefined)
275
+ continue;
276
+ const roster = rosterOf(entry);
277
+ if (roster !== undefined)
278
+ return roster;
279
+ }
280
+ for (const record of this.#persisted.values()) {
281
+ const roster = rosterOf(record);
282
+ if (roster !== undefined)
283
+ return roster;
284
+ }
285
+ return undefined;
286
+ }
287
+
288
+ /**
289
+ * Apply a model choice to a session, unless the session already runs it.
290
+ *
291
+ * Two properties matter and neither is obvious:
292
+ *
293
+ * 1. The choice is always resolved through the SESSION's own roster. That
294
+ * roster is the only list `session/set_config_option` accepts — the CLI's
295
+ * `--list-models` listing publishes aliases, and sending one fails the
296
+ * whole turn (`-32602 Invalid params: Invalid model value: auto`).
297
+ * 2. A request that names a model the account does not offer fails HERE,
298
+ * with the list of ids that would have worked, instead of reaching the
299
+ * CLI and coming back as an opaque protocol rejection.
300
+ *
301
+ * @param session - the session about to run.
302
+ * @param choice - the requested model, or `undefined` for "leave it alone".
303
+ * @returns the choice applied, or `undefined` when none was named.
304
+ */
305
+ async #setModelIfNeeded(session, choice) {
306
+ if (choice === undefined || choice === '')
307
+ return undefined;
308
+ const roster = rosterOf(session);
309
+ const resolved = resolveModel(choice, roster);
310
+ if (!resolved.ok) {
311
+ throw new CursorAcpError(
312
+ `cursor-dev: ${resolved.error}`,
313
+ 'cursor/model-unknown',
314
+ );
315
+ }
316
+ // A no-op switch is skipped rather than sent: the CLI applies its model
317
+ // globally, so re-setting the model it already reports would churn every
318
+ // other session's state for nothing. `sameModel` is what makes
319
+ // `auto`/`default[]` and an alias/its variant count as one model — the
320
+ // same comparison the panel and the tool make. The "already running"
321
+ // side is the FOLDED roster's current id, so the no-op test and the
322
+ // resolution above grade against one and the same list (a build that
323
+ // reports the current model only in `configOptions` must not look like a
324
+ // switch); the legacy block is kept as a fallback.
325
+ if (!sameModel(roster?.currentModelId ?? session.models?.currentModelId, resolved.id))
326
+ await session.setModel(resolved.id);
327
+ return resolved.id;
328
+ }
329
+
330
+ /**
331
+ * Pick the pool key for this turn.
332
+ *
333
+ * @param conversationKey - stable identity of the conversation.
334
+ * @param mode - `'continue'` reuses the current generation,
335
+ * `'fresh'` starts a new one (after a reset or an explicit non-continue).
336
+ * @returns the pool key and the generation number.
337
+ */
338
+ pickKey(conversationKey, mode) {
339
+ const current = this.#generations.get(conversationKey);
340
+ if (mode === 'continue' && current !== undefined)
341
+ return { key: generationKey(conversationKey, current), generation: current };
342
+ const next = (current ?? 0) + 1;
343
+ this.#generations.set(conversationKey, next);
344
+ return { key: generationKey(conversationKey, next), generation: next };
345
+ }
346
+
347
+ /**
348
+ * Run one Cursor turn under the global lock.
349
+ *
350
+ * The callback receives a lease: the resolved session plus its pool
351
+ * metadata. Everything the callback does — model switch, mode switch,
352
+ * prompt — happens with no other Cursor turn in flight anywhere in this
353
+ * process, which is what the CLI's global model state requires.
354
+ *
355
+ * @param options - `key`, `cwd`, `model`, `persistedSessionId`, `signal`.
356
+ * @param body - the turn itself; called with the acquired lease.
357
+ */
358
+ async run(options, body) {
359
+ const release = await this.#enter(options.signal);
360
+ try {
361
+ options.signal?.throwIfAborted();
362
+ const lease = await this.#acquire(options, options.signal);
363
+ try {
364
+ return await body(lease);
365
+ }
366
+ finally {
367
+ if (options.signal?.aborted) {
368
+ await lease.session.disposeAndWait();
369
+ if (this.#sessions.get(lease.key)?.session === lease.session)
370
+ this.#sessions.delete(lease.key);
371
+ }
372
+ this.#touch(lease.key);
373
+ }
374
+ }
375
+ finally {
376
+ release();
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Record a finished turn on a pooled entry so the status route can show it.
382
+ * @param key - pool key.
383
+ * @param turn - the turn summary returned by `CursorSession.prompt`.
384
+ */
385
+ recordTurn(key, turn) {
386
+ const entry = this.#sessions.get(key);
387
+ if (entry === undefined)
388
+ return;
389
+ entry.turns.push(turn);
390
+ if (entry.turns.length > 50)
391
+ entry.turns.shift();
392
+ entry.lastUsed = Date.now();
393
+ }
394
+
395
+ /** Mark a pooled entry as used now, so the reaper leaves it alone. */
396
+ #touch(key) {
397
+ const entry = this.#sessions.get(key);
398
+ if (entry !== undefined)
399
+ entry.lastUsed = Date.now();
400
+ }
401
+
402
+ /**
403
+ * Resolve a usable session for `options.key`, reusing, resuming, or spawning.
404
+ *
405
+ * Order of preference:
406
+ * 1. the live pooled session, when its cwd still matches;
407
+ * 2. `session/load` of the persisted id for this key — the durable path;
408
+ * 3. a brand-new `session/new`.
409
+ *
410
+ * @param options - `key`, `cwd`, `model`, `persistedSessionId`, `signal`.
411
+ */
412
+ async #acquire(options, signal) {
413
+ const { key, cwd, model, persistedSessionId, storeKey } = options;
414
+ signal?.throwIfAborted();
415
+ if (this.#disposed)
416
+ throw new Error('cursor-dev: plugin is unloading');
417
+
418
+ const spawnConfig = {
419
+ resolved: this.#config.resolved,
420
+ cwd,
421
+ env: await spawnEnv(this.#config),
422
+ spawn: this.#config.spawn,
423
+ };
424
+
425
+ const existing = this.#sessions.get(key);
426
+ if (existing !== undefined) {
427
+ if (existing.session.alive && existing.session.cwd === cwd) {
428
+ existing.lastUsed = Date.now();
429
+ const onAbort = () => existing.session.dispose();
430
+ signal?.addEventListener('abort', onAbort, { once: true });
431
+ try {
432
+ signal?.throwIfAborted();
433
+ existing.modelChoice = await this.#setModelIfNeeded(existing.session, model) ?? existing.modelChoice;
434
+ signal?.throwIfAborted();
435
+ return { key, session: existing.session, reused: true, reloaded: existing.reloaded, turns: existing.turns };
436
+ }
437
+ finally {
438
+ signal?.removeEventListener('abort', onAbort);
439
+ if (signal?.aborted) await existing.session.disposeAndWait();
440
+ }
441
+ }
442
+ // A different cwd (or a dead process) means this pooled entry is
443
+ // useless — drop it. `session/load` is the fallback that keeps the
444
+ // conversation itself alive.
445
+ this.#evict(key);
446
+ }
447
+
448
+ await this.#enforceLimit();
449
+
450
+ if (typeof persistedSessionId === 'string' && persistedSessionId !== '') {
451
+ const resumed = await this.#tryLoad(key, persistedSessionId, cwd, model, spawnConfig, signal, storeKey);
452
+ if (resumed !== undefined)
453
+ return resumed;
454
+ }
455
+
456
+ return this.#spawnFresh(key, cwd, model, spawnConfig, signal, storeKey);
457
+ }
458
+
459
+ /**
460
+ * Attempt `session/load`; a failure is not fatal (the caller falls back to a
461
+ * fresh session), but it is reported so a real regression stays visible.
462
+ *
463
+ * @param key - generation-scoped pool key.
464
+ * @param sessionId - the durable Cursor session id to resume.
465
+ * @param cwd - working directory the session must be bound to.
466
+ * @param model - this turn's requested model (same contract as `#spawnFresh`).
467
+ * @param spawnConfig - resolved agent + spawn + env.
468
+ * @param signal - the turn's abort signal.
469
+ * @param storeKey - conversation-scoped durable key for `#remember`.
470
+ */
471
+ async #tryLoad(key, sessionId, cwd, model, spawnConfig, signal, storeKey) {
472
+ const session = new CursorSession(spawnConfig, (event) => {
473
+ this.#onEvent(key, event);
474
+ });
475
+ try {
476
+ await session.load(sessionId, { signal });
477
+ }
478
+ catch (error) {
479
+ this.#onEvent(key, {
480
+ kind: 'load-failed',
481
+ sessionId,
482
+ error: error instanceof Error ? error.message : String(error),
483
+ });
484
+ session.kill();
485
+ await session.disposeAndWait();
486
+ signal?.throwIfAborted();
487
+ return undefined;
488
+ }
489
+ // The loaded session republishes the CLI's roster, and the CLI's model
490
+ // state is global — so the resumed conversation may be running whatever
491
+ // the LAST turn anywhere left behind. Reapplying this turn's choice here
492
+ // is what keeps a durable conversation on the model its caller asked for.
493
+ let applied;
494
+ try {
495
+ signal?.throwIfAborted();
496
+ applied = await this.#setModelIfNeeded(session, model);
497
+ signal?.throwIfAborted();
498
+ }
499
+ catch (error) {
500
+ await session.disposeAndWait();
501
+ throw error;
502
+ }
503
+ const entry = {
504
+ key,
505
+ session,
506
+ turns: [],
507
+ lastUsed: Date.now(),
508
+ reloaded: true,
509
+ modelChoice: applied,
510
+ };
511
+ this.#sessions.set(key, entry);
512
+ this.#remember(storeKey, sessionId, session, applied);
513
+ return { key, session, reused: true, reloaded: true, turns: entry.turns };
514
+ }
515
+
516
+ /** Spawn a brand-new Cursor session (nothing to resume, or resume failed). */
517
+ async #spawnFresh(key, cwd, model, spawnConfig, signal, storeKey) {
518
+ const session = new CursorSession(spawnConfig, (event) => {
519
+ this.#onEvent(key, event);
520
+ });
521
+ let applied;
522
+ try {
523
+ await session.start(signal);
524
+ signal?.throwIfAborted();
525
+ applied = await this.#setModelIfNeeded(session, model);
526
+ signal?.throwIfAborted();
527
+ }
528
+ catch (error) {
529
+ // A fresh session that cannot take the requested model is worthless:
530
+ // dropping it keeps the pool free of a process pinned to the wrong
531
+ // model, and the caller still sees why the switch failed.
532
+ await session.disposeAndWait();
533
+ throw error;
534
+ }
535
+ const entry = {
536
+ key,
537
+ session,
538
+ turns: [],
539
+ lastUsed: Date.now(),
540
+ reloaded: false,
541
+ modelChoice: applied,
542
+ };
543
+ this.#sessions.set(key, entry);
544
+ this.#remember(storeKey, session.sessionId, session, applied);
545
+ return { key, session, reused: false, reloaded: false, turns: entry.turns };
546
+ }
547
+
548
+ /**
549
+ * Record a durable session id for a conversation line.
550
+ *
551
+ * `storeKey` is the conversation-scoped durable key (`parent::label#0`),
552
+ * deliberately NOT the generation-scoped pool key (`…#2`): a fresh run —
553
+ * `continueSession:false`, a model switch, a reset — gets its own
554
+ * generation so it cannot inherit live context, yet it still wants to load
555
+ * the conversation's latest transcript rather than start from nothing.
556
+ *
557
+ * @param storeKey - conversation-scoped durable key.
558
+ * @param sessionId - the Cursor session id to remember.
559
+ * @param session - the session it belongs to.
560
+ * @param modelChoice - the model choice this turn applied, not the roster's spelling.
561
+ */
562
+ #remember(storeKey, sessionId, session, modelChoice) {
563
+ const durable = storeKey ?? session.cwd;
564
+ if (typeof sessionId !== 'string' || sessionId === '')
565
+ return;
566
+ this.#persisted.set(durable, {
567
+ sessionId,
568
+ cwd: session.cwd,
569
+ // The RESOLVED id is the canonical model: it is what the CLI
570
+ // actually speaks, and what a later turn must compare against.
571
+ model: session.models?.currentModelId,
572
+ modelChoice,
573
+ // The roster the CLI published, kept so a later process can answer
574
+ // "is this model usable at all?" without spawning a probe first.
575
+ // Stored in the same folded shape the live session exposes, so a
576
+ // resuming session is not left with an empty list simply because
577
+ // this build reports its models through `configOptions` only.
578
+ models: rosterOf(session),
579
+ });
580
+ }
581
+
582
+ /**
583
+ * Ask a throwaway session which model ids this account can use.
584
+ *
585
+ * The CLI's own `--list-models` listing is not a usable source for switching
586
+ * (`set_config_option` rejects its aliases), so the panel needs the ACP
587
+ * session roster. Reading it means spawning a process, which must not
588
+ * disturb the pooled entries: this probe runs on its own session with no
589
+ * key, is never stored, and is always disposed — successful or not.
590
+ *
591
+ * @param options - `timeoutMs` (default 30s) and an optional `cwd`.
592
+ * @returns `{ok: true, list}` with `listRosterModels`-shaped entries, or
593
+ * `{ok: false, list: [], error}` with the reason the probe could not answer.
594
+ */
595
+ async probeModelRoster(options = {}) {
596
+ const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs)
597
+ ? options.timeoutMs
598
+ : PROBE_TIMEOUT_MS;
599
+ const abort = new AbortController();
600
+ // A hung CLI must not wedge the panel's model list; the abort only ends
601
+ // this probe, never a pooled turn.
602
+ const timer = setTimeout(() => abort.abort(), timeoutMs);
603
+ timer.unref?.();
604
+ const session = new CursorSession({
605
+ resolved: this.#config.resolved,
606
+ cwd: options.cwd ?? process.cwd(),
607
+ env: await spawnEnv(this.#config),
608
+ spawn: this.#config.spawn,
609
+ }, () => {
610
+ // The probe has no journal and no pool entry to report to.
611
+ });
612
+ try {
613
+ await session.start(abort.signal);
614
+ const roster = rosterOf(session);
615
+ const list = listRosterModels(roster);
616
+ if (list.length === 0)
617
+ return { ok: false, list: [], error: 'the Cursor session reported no models' };
618
+ return { ok: true, list };
619
+ }
620
+ catch (error) {
621
+ return { ok: false, list: [], error: errorText(error) };
622
+ }
623
+ finally {
624
+ clearTimeout(timer);
625
+ try {
626
+ session.dispose();
627
+ }
628
+ catch {
629
+ // The probe's process is already gone; nothing else to release.
630
+ }
631
+ }
632
+ }
633
+
634
+ /**
635
+ * The durable Cursor session id recorded for a conversation key, if any.
636
+ * @param storeKey - conversation-scoped durable key.
637
+ */
638
+ persistedFor(storeKey) {
639
+ return this.#persisted.get(storeKey);
640
+ }
641
+
642
+ /**
643
+ * Read an OLD Cursor conversation back out of the CLI's own store.
644
+ *
645
+ * This is the one path that only reads: it takes the same global turn lock
646
+ * as a real run (the CLI's model choice is process-global, so a replay must
647
+ * not interleave with a turn), spawns a THROWAWAY session, loads `sessionId`
648
+ * with transcript capture armed, and hands the collected events to `body`.
649
+ * The session is never pooled — a durable row's key is `parent::label` while
650
+ * pool keys carry a generation suffix — and it is always disposed, so a
651
+ * replay can never steal or disturb the live entry of a conversation.
652
+ *
653
+ * The replay deliberately does NOT switch the model: reading history must
654
+ * not be able to fail on a roster that no longer lists the id the
655
+ * conversation ran with, and the next real turn sets its own model anyway.
656
+ *
657
+ * @param options - `sessionId`, `cwd`, `signal`.
658
+ * @param body - receives `{session, events}`.
659
+ * @returns whatever `body` returns.
660
+ */
661
+ async replay(options, body) {
662
+ const { sessionId, cwd, signal } = options;
663
+ const release = await this.#enter();
664
+ let session;
665
+ try {
666
+ // The same Cursor session must not be open twice at once: the CLI's
667
+ // store is single-writer, so a live entry holding this very session
668
+ // is closed first. Nothing is lost — the durable record survives, so
669
+ // the next real turn loads it again exactly as it does after an idle
670
+ // reap.
671
+ for (const [key, entry] of [...this.#sessions]) {
672
+ if (entry.session.sessionId === sessionId)
673
+ this.#evict(key);
674
+ }
675
+ session = new CursorSession({
676
+ resolved: this.#config.resolved,
677
+ cwd,
678
+ env: await spawnEnv(this.#config),
679
+ spawn: this.#config.spawn,
680
+ }, () => {
681
+ // The replay is collected through `onTranscript`, which is armed
682
+ // only while the load runs; the live sink has nothing to add.
683
+ });
684
+ const events = [];
685
+ await session.load(sessionId, {
686
+ signal,
687
+ onTranscript: (event) => {
688
+ events.push(event);
689
+ },
690
+ });
691
+ return await body({ session, events });
692
+ }
693
+ finally {
694
+ try {
695
+ session?.dispose();
696
+ }
697
+ catch {
698
+ // The replay process is already gone; nothing else to release.
699
+ }
700
+ release();
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Enter the global turn lock, returning the release function.
706
+ *
707
+ * A plain promise chain would let a long run starve later callers only if
708
+ * they were ordered unfairly — a FIFO gate is fair by construction, and the
709
+ * depth counter lets the status route report what is queued.
710
+ */
711
+ async #enter(signal) {
712
+ signal?.throwIfAborted();
713
+ this.#lockDepth += 1;
714
+ let releaseGate;
715
+ const previous = this.#gate;
716
+ this.#gate = new Promise(resolve => { releaseGate = resolve; });
717
+ let rejectAbort;
718
+ const cancelled = new Promise((_, reject) => { rejectAbort = reject; });
719
+ const onAbort = () => rejectAbort(signal.reason ?? new Error('cursor: queued run cancelled'));
720
+ signal?.addEventListener('abort', onAbort, { once: true });
721
+ try {
722
+ if (signal?.aborted) onAbort();
723
+ await Promise.race([previous, cancelled]);
724
+ signal?.throwIfAborted();
725
+ }
726
+ catch (error) {
727
+ this.#lockDepth -= 1;
728
+ // Preserve FIFO order even though this waiter has already left.
729
+ void previous.then(releaseGate);
730
+ throw error;
731
+ }
732
+ finally {
733
+ signal?.removeEventListener('abort', onAbort);
734
+ }
735
+ let released = false;
736
+ return () => {
737
+ if (released) return;
738
+ released = true;
739
+ this.#lockDepth -= 1;
740
+ releaseGate();
741
+ };
742
+ }
743
+
744
+ /** How many turns are in flight or queued behind the lock. */
745
+ get queued() {
746
+ return Math.max(0, this.#lockDepth - 1);
747
+ }
748
+
749
+ /**
750
+ * Drop a session, terminating its process. The durable id is kept unless
751
+ * `forget` is set, so the conversation can still be resumed later.
752
+ * @param key - pool key (generation-scoped).
753
+ * @param forget - also drop the durable conversation record for this key.
754
+ */
755
+ #evict(key, forget = false) {
756
+ const entry = this.#sessions.get(key);
757
+ if (entry !== undefined) {
758
+ this.#sessions.delete(key);
759
+ entry.session.dispose();
760
+ }
761
+ if (forget)
762
+ this.#persisted.delete(storeKeyOf(key));
763
+ }
764
+
765
+ /**
766
+ * Public eviction, used by the tool's `resetSession` switch. This is the one
767
+ * path that deliberately FORGETS the Cursor session id.
768
+ * @param key - pool key.
769
+ */
770
+ drop(key) {
771
+ this.#evict(key, true);
772
+ }
773
+
774
+ /**
775
+ * Terminate every live generation of one conversation.
776
+ * @param conversationKey - `parent::label`.
777
+ */
778
+ dropConversation(conversationKey) {
779
+ for (const key of [...this.#sessions.keys()]) {
780
+ if (key.startsWith(`${conversationKey}#`))
781
+ this.#evict(key, true);
782
+ }
783
+ this.#persisted.delete(conversationKey);
784
+ this.#generations.delete(conversationKey);
785
+ }
786
+
787
+ /** Reap every session idle longer than the configured window. */
788
+ reapIdle() {
789
+ const now = Date.now();
790
+ for (const [key, entry] of [...this.#sessions]) {
791
+ if (now - entry.lastUsed > this.#idleMs)
792
+ this.#evict(key);
793
+ }
794
+ }
795
+
796
+ /** Keep the live-process count under the configured cap. */
797
+ async #enforceLimit() {
798
+ while (this.#sessions.size >= this.#maxLive) {
799
+ let oldestKey;
800
+ let oldestAt = Number.POSITIVE_INFINITY;
801
+ for (const [key, entry] of this.#sessions) {
802
+ if (entry.lastUsed < oldestAt) {
803
+ oldestAt = entry.lastUsed;
804
+ oldestKey = key;
805
+ }
806
+ }
807
+ if (oldestKey === undefined)
808
+ return;
809
+ this.#evict(oldestKey);
810
+ }
811
+ }
812
+
813
+ /** Terminate every live session (plugin unload / HMR). */
814
+ dispose() {
815
+ this.#disposed = true;
816
+ if (this.#reaper !== undefined) {
817
+ clearInterval(this.#reaper);
818
+ this.#reaper = undefined;
819
+ }
820
+ for (const key of [...this.#sessions.keys()])
821
+ this.#evict(key);
822
+ this.#generations.clear();
823
+ }
824
+ }
825
+
826
+ /**
827
+ * The environment a child is spawned with.
828
+ *
829
+ * The pool takes either a plain object or a function, and the indirection earns
830
+ * its keep: the proxy a child needs is discovered asynchronously (on Windows the
831
+ * registry has to be asked), and reading it per spawn means a proxy saved in the
832
+ * panel a second ago already applies to the next run instead of the next restart.
833
+ *
834
+ * The getter may answer a promise, and awaiting it is the point rather than a
835
+ * convenience: the very first spawn can beat the first registry lookup, and a
836
+ * child that starts in that window silently lists a fraction of the models.
837
+ *
838
+ * @param config - the pool config.
839
+ */
840
+ async function spawnEnv(config) {
841
+ const env = config?.env;
842
+ return typeof env === 'function' ? await env() : env;
843
+ }
844
+
845
+ /**
846
+ * Compose a generation-scoped pool key.
847
+ *
848
+ * @param conversationKey - `parent::label`.
849
+ * @param generation - generation number.
850
+ */
851
+ function generationKey(conversationKey, generation) {
852
+ return `${conversationKey}#${generation}`;
853
+ }
854
+
855
+ /**
856
+ * Map a generation-scoped pool key back to its conversation-scoped durable key.
857
+ * `parent::label#3` → `parent::label#0`.
858
+ *
859
+ * @param poolKey - generation-scoped key.
860
+ */
861
+ export function storeKeyOf(poolKey) {
862
+ const hash = poolKey.lastIndexOf('#');
863
+ return hash === -1 ? poolKey : `${poolKey.slice(0, hash)}#0`;
864
+ }
865
+
866
+ /**
867
+ * Strip the generation suffix from a pool key: `parent::label#3` → `parent::label`.
868
+ *
869
+ * This is *not* `storeKeyOf`, which normalises to generation `#0` for the pool's
870
+ * own bookkeeping. The durable conversation index is written under the bare
871
+ * conversation-scoped key (`lib/tool.js` stores `${parentId}::${label}`), while
872
+ * every key the panel shows for a live row is generation-scoped. Looking such a
873
+ * row up in the index verbatim therefore always misses, which is what made
874
+ * "reading back" a live conversation answer `no durable conversation for that key`.
875
+ *
876
+ * @param poolKey - generation-scoped or conversation-scoped key.
877
+ */
878
+ export function conversationKeyOf(poolKey) {
879
+ const hash = poolKey.lastIndexOf('#');
880
+ return hash === -1 ? poolKey : poolKey.slice(0, hash);
881
+ }
882
+
883
+ /**
884
+ * Folded model roster for a pool entry, or `undefined` when none is known yet.
885
+ *
886
+ * A live entry is asked through `session.modelRoster`, the ACP-shaped view that
887
+ * folds `configOptions` (authoritative, holds the parameterised ids) with the
888
+ * legacy `models` block — the same list `session.setModel()` grades against, so
889
+ * resolution and the no-op comparison can never disagree about what "this
890
+ * session's models" means. A resumed conversation has no live session and falls
891
+ * back to the legacy-shaped roster kept in the durable record, which is
892
+ * `undefined` for a record that carries only `modelChoice`/`model` — that must
893
+ * stay `undefined` rather than become a made-up match.
894
+ *
895
+ * @param entry - a live pool entry (`{session, ...}`) or a durable record.
896
+ */
897
+ function rosterOf(entry) {
898
+ if (entry === null || entry === undefined)
899
+ return undefined;
900
+ // Two object shapes reach here and they must not be confused: a pool ENTRY
901
+ // (`{session, …}`, what this pool holds) and a bare live SESSION (what
902
+ // `probeModelRoster` builds). An entry is recognized by holding one of the
903
+ // pool's own fields; anything else IS the session. A live session is always
904
+ // asked through the ACP-shaped getter — never through its legacy `models`
905
+ // block, which is not a roster argument and would make
906
+ // `rosterFromSession()` answer `undefined`.
907
+ const isEntry = 'session' in entry || 'lastUsed' in entry || 'reloaded' in entry || 'timesUsed' in entry;
908
+ const session = isEntry ? entry.session : entry;
909
+ const live = session?.modelRoster;
910
+ if (live !== undefined)
911
+ return live;
912
+ const record = entry.models;
913
+ if (record === undefined)
914
+ return undefined;
915
+ const fallbackCurrent = record.currentModelId ?? entry.modelChoice ?? entry.model;
916
+ // `rosterFromSession` reads a SESSION-shaped object and reaches a legacy
917
+ // block through `session.models`, so the block has to be handed over inside
918
+ // that wrapper. Passing the block itself made the reader look for
919
+ // `block.models`, find nothing, and answer `undefined` — which silently
920
+ // turned every durable record into "no roster at all".
921
+ return rosterFromSession({ models: record }, fallbackCurrent);
922
+ }
923
+
924
+ /** Render an unknown thrown value as text (probe results never throw out). */
925
+ function errorText(error) {
926
+ return error instanceof Error ? error.message : String(error);
927
+ }