dsh-cursor-dev 0.0.0-stage → 0.11.1
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 +21 -0
- package/README.md +263 -2
- package/cordis.patch.yml +12 -0
- package/docs/INSTALL.md +219 -0
- package/lib/changes.js +192 -0
- package/lib/cli.js +237 -0
- package/lib/client.js +626 -0
- package/lib/command.js +271 -0
- package/lib/conversations.js +101 -0
- package/lib/cursor-session.js +871 -0
- package/lib/fence.js +123 -0
- package/lib/index.js +477 -0
- package/lib/journal.js +276 -0
- package/lib/mirror.js +1293 -0
- package/lib/models.js +314 -0
- package/lib/native-provider.js +224 -0
- package/lib/prompt.js +59 -0
- package/lib/proxy.js +414 -0
- package/lib/resolve-agent.js +251 -0
- package/lib/session-pool.js +927 -0
- package/lib/settings.js +642 -0
- package/lib/tool.js +747 -0
- package/lib/usage.js +2262 -0
- package/package.json +60 -4
- package/screenshots/panel-zh-settings.png +0 -0
- package/screenshots/panel-zh.png +0 -0
|
@@ -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
|
+
}
|