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,871 @@
1
+ /**
2
+ * One live Cursor ACP session: process + protocol connection + prompt turns.
3
+ *
4
+ * This is the piece the stock `@deepseek-ai/dsh-subagent-acp` provider does not
5
+ * offer. Its provider is one-shot: `start()` spawns, prompts once, discards the
6
+ * child. Here a session is a first-class handle that can carry MANY turns and
7
+ * reports every `session/update` the Cursor agent emits — reasoning chunks,
8
+ * tool calls, and the real file diffs it applies.
9
+ *
10
+ * Wire notes proven by probe (see `docs/PROBE-FINDINGS.md`):
11
+ * - spawn `[node.exe, index.js, (--model <id>), acp]`; cwd = target workspace.
12
+ * - `session/update` notifications MUST be subscribed with the SDK's RAW parser
13
+ * form `onNotification('session/update', p => p, handler)`: the typed form
14
+ * throws `Cannot read properties of undefined (reading 'sessionUpdate')` for
15
+ * update kinds this SDK build does not model (`session_info_update`,
16
+ * `available_commands_update`, …).
17
+ * - `session/new` returns `sessionId`, `modes{currentModeId,availableModes}`, a
18
+ * legacy `models{currentModelId,availableModels[{modelId,name}]}` block and a
19
+ * `configOptions` array whose `model` option is the authoritative roster.
20
+ * - Model ids live in TWO name spaces, and mixing them is what used to fail
21
+ * every switch. `session/set_config_option` accepts ONLY the parameterised
22
+ * ids the roster publishes (`default[]`, `composer-2.5[fast=true]`,
23
+ * `kimi-k3[reasoning=max]`); the CLI's own `--list-models` prints the bare
24
+ * ALIASES (`auto`, `composer-2.5`) that the panel stores, and sending one is
25
+ * rejected `-32602 Invalid params: Invalid model value: auto`. `auto` — and an
26
+ * empty choice — means the roster's `default[]`. `lib/models.js` owns that
27
+ * mapping, so this file never hands a raw panel value to the protocol.
28
+ * - edit tool calls report `{type:'diff', path, oldText, newText}` in
29
+ * `tool_call_update.content`, plus `locations:[{path}]` and `rawInput.path`.
30
+ *
31
+ * Cross-process continuation (probed, see `.test/acp-probe/ws2/REPORT.md`):
32
+ * - `session/load {sessionId, cwd, mcpServers}` rehydrates a conversation in a
33
+ * brand-new process and DOES recall prior turns.
34
+ * - It returns `{modes, models, configOptions}` — **no `sessionId`** — so the id
35
+ * the caller supplied must be kept.
36
+ * - It REPLAYS the whole transcript as `session/update` notifications before the
37
+ * response settles. Replay is idempotent, so it is suppressed here: a resumed
38
+ * session starts with empty turn/diff accumulators on purpose.
39
+ * - `cwd` is not validated against the stored one; the caller decides the cwd.
40
+ * - `session/resume` and `session/close` are BOTH `-32601 Method not found`.
41
+ * - A session is not loadable until at least one prompt has flushed it to disk;
42
+ * before that `load` fails `-32602 Session "<id>" not found`.
43
+ *
44
+ * Model switching is GLOBAL to the CLI, not per session: setting the model on
45
+ * one session changes it for every session in every process (probed twice,
46
+ * including across a process restart). The pool therefore serializes turns and
47
+ * {@link CursorSession.setModel} is only ever called while that lock is held.
48
+ */
49
+ import { Readable, Writable } from 'node:stream';
50
+ import { client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
51
+ import { baseName, resolveModel, rosterFromConfigOptions, rosterFromSession } from './models.js';
52
+ import { acpArgv } from './resolve-agent.js';
53
+
54
+ /** Grace period for a cancelled turn to settle before the process is killed. */
55
+ const CANCEL_GRACE_MS = 4000;
56
+
57
+ /**
58
+ * Error carrying an ACP-level failure code for tool-facing diagnostics.
59
+ */
60
+ export class CursorAcpError extends Error {
61
+ code;
62
+ constructor(message, code = 'cursor/acp-failed') {
63
+ super(message);
64
+ this.name = 'CursorAcpError';
65
+ this.code = code;
66
+ }
67
+ }
68
+
69
+ /** The `/dev/null` marker Cursor sends as `oldText` when a file did not exist. */
70
+ const DEV_NULL = /^\s*(?:[-+]+\s*)?\/dev\/null\s*$/;
71
+ /** A git-style file header line, e.g. `++ b/C:\work\new.txt` or `--- a/x.js`. */
72
+ const DIFF_HEADER = /^(?:[-+]{2,3}|\+\+\+?|--+) (?:[ab]\/|\/dev\/null)/;
73
+
74
+ /**
75
+ * Strip a git-style file header Cursor prepends to the content of a NEW file.
76
+ *
77
+ * Only the first line is considered, and only when it really is a header, so a
78
+ * file whose own first line looks like `--- something` is left alone.
79
+ *
80
+ * @param text - candidate content.
81
+ */
82
+ function stripDiffHeader(text) {
83
+ const lines = text.split('\n');
84
+ if (lines.length > 1 && DIFF_HEADER.test(lines[0]))
85
+ lines.shift();
86
+ return lines.join('\n');
87
+ }
88
+
89
+ /**
90
+ * Normalize one ACP `diff` part into real file contents.
91
+ *
92
+ * Cursor reports an edit as the true old/new file text, but reports a CREATED
93
+ * file as a git-style pseudo-diff:
94
+ *
95
+ * oldText: "-- /dev/null"
96
+ * newText: "++ b/C:\\work\\new.txt\nDIFFPROBE"
97
+ *
98
+ * Passing that through verbatim makes a created file look modified and renders
99
+ * `-- /dev/null` in the UI diff card, so it is converted into `{oldText: null}`
100
+ * plus the bare content. Exported for direct testing.
101
+ *
102
+ * @param part - one `content` entry of a `tool_call_update`.
103
+ */
104
+ export function normalizeAcpDiff(part) {
105
+ const path = part.path;
106
+ const rawOld = typeof part.oldText === 'string' ? part.oldText : '';
107
+ const rawNew = typeof part.newText === 'string' ? part.newText : '';
108
+ const isNew = rawOld === '' || DEV_NULL.test(rawOld);
109
+ if (isNew) {
110
+ return {
111
+ path,
112
+ oldText: null,
113
+ newText: DIFF_HEADER.test(rawNew.split('\n')[0]) ? stripDiffHeader(rawNew) : rawNew,
114
+ };
115
+ }
116
+ return {
117
+ path,
118
+ oldText: rawOld,
119
+ newText: DEV_NULL.test(rawNew) ? '' : rawNew,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Normalize the several shapes one `tool_call_update` may carry into a diff list.
125
+ * @param update - a `tool_call` or `tool_call_update` payload.
126
+ */
127
+ function extractDiffs(update) {
128
+ const content = update.content;
129
+ if (!Array.isArray(content))
130
+ return [];
131
+ const diffs = [];
132
+ for (const part of content) {
133
+ if (part !== null && typeof part === 'object' && part.type === 'diff' && typeof part.path === 'string')
134
+ diffs.push(normalizeAcpDiff(part));
135
+ }
136
+ return diffs;
137
+ }
138
+
139
+ /**
140
+ * Best-effort file path for a tool call part, from `locations` or `rawInput`.
141
+ * @param update - a `tool_call` or `tool_call_update` payload.
142
+ */
143
+ function extractPath(update) {
144
+ const locations = update.locations;
145
+ if (Array.isArray(locations)) {
146
+ for (const location of locations) {
147
+ if (location !== null && typeof location === 'object' && typeof location.path === 'string')
148
+ return location.path;
149
+ }
150
+ }
151
+ const raw = update.rawInput;
152
+ if (raw !== null && typeof raw === 'object' && typeof raw.path === 'string')
153
+ return raw.path;
154
+ return undefined;
155
+ }
156
+
157
+ /**
158
+ * Strip the tool-call-id wrapping Cursor adds (`call-<uuid>-<n>` plus newline noise).
159
+ * @param id - raw ACP tool call id.
160
+ */
161
+ function normalizeCallId(id) {
162
+ if (typeof id !== 'string')
163
+ return 'unknown';
164
+ const collapsed = id.replace(/\s+/g, '');
165
+ return collapsed.length > 64 ? collapsed.slice(0, 64) : collapsed;
166
+ }
167
+
168
+ /**
169
+ * One live Cursor ACP conversation.
170
+ */
171
+ export class CursorSession {
172
+ #config;
173
+ #handle;
174
+ #child;
175
+ #connection;
176
+ #agent;
177
+ #sessionId;
178
+ #capabilities;
179
+ #models;
180
+ #configOptions;
181
+ #modes;
182
+ #cwd;
183
+ #sequence = 0;
184
+ #activeTurn;
185
+ #closed = false;
186
+ #stderr = [];
187
+ #onEvent;
188
+ #hasContent = false;
189
+ #replaying = false;
190
+ #transcript;
191
+
192
+ /**
193
+ * @param config - resolved spawn config (command, entry, model, env, cwd).
194
+ * @param onEvent - sink for every normalized event (already redacted upstream).
195
+ */
196
+ constructor(config, onEvent) {
197
+ this.#config = config;
198
+ this.#onEvent = onEvent;
199
+ }
200
+
201
+ /** Durable Cursor session id, available after {@link start}. */
202
+ get sessionId() {
203
+ return this.#sessionId;
204
+ }
205
+
206
+ /** Working directory the child was bound to. */
207
+ get cwd() {
208
+ return this.#cwd;
209
+ }
210
+
211
+ /** Whether the underlying process is still usable. */
212
+ get alive() {
213
+ return !this.#closed && this.#child !== undefined && this.#child.exitCode === null;
214
+ }
215
+
216
+ /** Agent capability block from `initialize` (loadSession, promptCapabilities, …). */
217
+ get capabilities() {
218
+ return this.#capabilities;
219
+ }
220
+
221
+ /**
222
+ * Session model roster in the LEGACY shape (`{currentModelId, availableModels[]}`).
223
+ *
224
+ * Kept because the pool and the tool result read `models.currentModelId`;
225
+ * {@link modelRoster} is the ACP-shaped view that model switching uses.
226
+ */
227
+ get models() {
228
+ return this.#models;
229
+ }
230
+
231
+ /**
232
+ * The model ids this session can actually be switched to, or `undefined`
233
+ * when the build reports none.
234
+ *
235
+ * Derived from the `configOptions` array of the newest `session/new`,
236
+ * `session/load` or `config_option_update`, falling back to the legacy
237
+ * `models` block, so every reader sees one folded `{currentModelId,
238
+ * availableModels[]}` shape.
239
+ */
240
+ get modelRoster() {
241
+ return rosterFromSession({ configOptions: this.#configOptions, models: this.#models });
242
+ }
243
+
244
+ /** Session mode roster (`{currentModeId, availableModes[]}`). */
245
+ get modes() {
246
+ return this.#modes;
247
+ }
248
+
249
+ /** Recent stderr text, for diagnostics when the child misbehaves. */
250
+ get stderrTail() {
251
+ return this.#stderr.join('');
252
+ }
253
+
254
+ /**
255
+ * Whether this session already holds conversation content.
256
+ *
257
+ * A `session/new` session with no completed prompt has NOT been flushed to
258
+ * the CLI's store yet and is therefore not loadable by any process — the
259
+ * pool uses this to decide whether resuming is even worth attempting.
260
+ */
261
+ get resumable() {
262
+ return this.#hasContent;
263
+ }
264
+
265
+ /**
266
+ * Spawn the Cursor process, initialize ACP, and open a session.
267
+ * @param signal - caller cancellation; aborts the whole session.
268
+ */
269
+ async start(signal) {
270
+ await this.#spawn(signal);
271
+ await this.#openSession({ signal });
272
+ return this;
273
+ }
274
+
275
+ /**
276
+ * Start a process and REHYDRATE an existing Cursor session in it.
277
+ *
278
+ * This is what makes continuity survive the parent ending its turn, DSH
279
+ * restarting, or the OS reaping the child: the conversation lives in the
280
+ * CLI's own store keyed by `sessionId`, so any later process can load it.
281
+ *
282
+ * @param sessionId - a session id previously returned by `session/new`.
283
+ * @param options - `signal` for cancellation, plus an optional
284
+ * `onTranscript` sink that receives every normalized event of the replayed
285
+ * history (armed for the duration of the load only). Without it the replay
286
+ * is counted, never surfaced: a caller that merely resumes work already
287
+ * owns the conversation and must not see it twice.
288
+ */
289
+ async load(sessionId, options = {}) {
290
+ if (typeof sessionId !== 'string' || sessionId === '')
291
+ throw new CursorAcpError('cursor-acp: session/load requires a session id', 'cursor/load-invalid');
292
+ await this.#spawn(options.signal);
293
+ this.#transcript = typeof options.onTranscript === 'function' ? options.onTranscript : undefined;
294
+ try {
295
+ await this.#openSession({ sessionId, signal: options.signal });
296
+ }
297
+ catch (error) {
298
+ this.dispose();
299
+ throw error;
300
+ }
301
+ finally {
302
+ this.#transcript = undefined;
303
+ }
304
+ return this;
305
+ }
306
+
307
+ /**
308
+ * Spawn the child process and wire the ACP client to its stdio.
309
+ * @param signal - caller cancellation.
310
+ */
311
+ async #spawn(signal) {
312
+ const config = this.#config;
313
+ const argv = acpArgv(config.resolved, config.model);
314
+ const handle = config.spawn({
315
+ argv: [config.resolved.command, ...argv],
316
+ cwd: config.cwd,
317
+ stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
318
+ graceMs: 3000,
319
+ env: config.env,
320
+ signal,
321
+ });
322
+ this.#handle = handle;
323
+ this.#cwd = config.cwd;
324
+
325
+ const stdin = handle.stdin;
326
+ const stdout = handle.stdout;
327
+ if (stdin === undefined || stdout === undefined) {
328
+ handle.terminate();
329
+ throw new CursorAcpError('cursor-acp: subprocess handle exposed no stdio pipes', 'cursor/spawn-failed');
330
+ }
331
+
332
+ handle.stderr?.on('data', (chunk) => {
333
+ this.#stderr.push(String(chunk));
334
+ if (this.#stderr.length > 40)
335
+ this.#stderr.shift();
336
+ });
337
+
338
+ const child = { exitCode: null, stderr: handle.stderr };
339
+ handle.done.then(() => {
340
+ child.exitCode = 0;
341
+ }, () => {
342
+ child.exitCode = 1;
343
+ });
344
+ this.#child = child;
345
+
346
+ const stream = ndJsonStream(Writable.toWeb(stdin), Readable.toWeb(stdout));
347
+
348
+ const app = client({ name: 'dsh-cursor-dev' })
349
+ .onNotification('session/update', (params) => params, (ctx) => {
350
+ const update = ctx.notification?.update ?? ctx.params?.update;
351
+ if (update === undefined)
352
+ return;
353
+ // A `session/load` replays the entire prior transcript before it
354
+ // settles. A caller that only wants continuity already knows that
355
+ // history, so the replay is counted, not surfaced. A caller that
356
+ // ASKED for the transcript (the restore path) gets it through
357
+ // `#ingest`, which is the same translation every live update goes
358
+ // through — one vocabulary for both.
359
+ if (this.#replaying && this.#transcript === undefined)
360
+ return;
361
+ this.#ingest(update);
362
+ })
363
+ .onRequest(methods.client.session.requestPermission, (ctx) => {
364
+ // The child runs autonomously as a coding subagent; the provider
365
+ // boundary (and the parent's own approval policy) already decided
366
+ // this delegation is permitted, so select the broadest grant.
367
+ const options = ctx.params.options ?? [];
368
+ const choice = options.find(option => option.kind === 'allow_always')
369
+ ?? options.find(option => option.kind === 'allow_once')
370
+ ?? options[0];
371
+ this.#emit({ kind: 'permission', optionId: choice?.optionId, kindOf: choice?.kind });
372
+ return { outcome: { outcome: 'selected', optionId: choice?.optionId } };
373
+ });
374
+
375
+ this.#connection = app.connect(stream);
376
+ this.#agent = this.#connection.agent;
377
+ }
378
+
379
+ /**
380
+ * Initialize ACP and either create or load the session.
381
+ * @param options - `sessionId` to rehydrate; omitted creates a new session.
382
+ */
383
+ async #openSession(options) {
384
+ const { sessionId, signal } = options;
385
+ const config = this.#config;
386
+ const agent = this.#agent;
387
+
388
+ try {
389
+ const init = await agent.request(methods.agent.initialize, {
390
+ protocolVersion: 1,
391
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true },
392
+ clientInfo: { name: 'dsh-cursor-dev', version: '0.1.0' },
393
+ });
394
+ this.#capabilities = init?.agentCapabilities;
395
+ }
396
+ catch (error) {
397
+ this.dispose();
398
+ throw new CursorAcpError(`cursor-acp: initialize failed: ${message(error)}`, 'cursor/initialize-failed');
399
+ }
400
+
401
+ if (sessionId !== undefined) {
402
+ this.#replaying = true;
403
+ // A replay turn keeps `tool_call` and its later `tool_call_update`
404
+ // merged into ONE call object (the update carries no title), which is
405
+ // what the transcript collector needs to rebuild a faithful timeline.
406
+ if (this.#transcript !== undefined)
407
+ this.#activeTurn = { index: 0, text: '', reasoning: '', calls: new Map(), diffs: [] };
408
+ try {
409
+ const loaded = await agent.request(methods.agent.session.load, {
410
+ sessionId,
411
+ cwd: config.cwd,
412
+ mcpServers: [],
413
+ });
414
+ // `session/load` does NOT echo the session id back.
415
+ this.#sessionId = sessionId;
416
+ this.#adoptRoster(loaded);
417
+ this.#modes = loaded?.modes;
418
+ this.#hasContent = true;
419
+ this.#emit({ kind: 'session-loaded', sessionId, history: true });
420
+ }
421
+ catch (error) {
422
+ this.dispose();
423
+ throw new CursorAcpError(`cursor-acp: session/load failed: ${message(error)}`, 'cursor/load-failed');
424
+ }
425
+ finally {
426
+ this.#replaying = false;
427
+ this.#activeTurn = undefined;
428
+ }
429
+ return;
430
+ }
431
+
432
+ try {
433
+ const session = await agent.request(methods.agent.session.new, {
434
+ cwd: config.cwd,
435
+ mcpServers: [],
436
+ });
437
+ this.#sessionId = session.sessionId;
438
+ this.#adoptRoster(session);
439
+ this.#modes = session.modes;
440
+ }
441
+ catch (error) {
442
+ this.dispose();
443
+ throw new CursorAcpError(`cursor-acp: session/new failed: ${message(error)}`, 'cursor/session-failed');
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Adopt the model roster a session response reports.
449
+ *
450
+ * The `configOptions` array is the authoritative roster: its `model` option
451
+ * lists exactly the ids `set_config_option` accepts. The legacy `models`
452
+ * block is mirrored as it stands, because the pool and the tool result read
453
+ * `models.currentModelId`, and a build that reports only `configOptions`
454
+ * gets a legacy-shaped mirror of the current id so those readers still work.
455
+ *
456
+ * @param response - a `session/new` or `session/load` response.
457
+ */
458
+ #adoptRoster(response) {
459
+ const roster = rosterFromSession(response, this.modelRoster?.currentModelId);
460
+ if (Array.isArray(response?.configOptions))
461
+ this.#configOptions = response.configOptions;
462
+ const legacy = response?.models;
463
+ if (legacy !== null && typeof legacy === 'object') {
464
+ this.#models = { ...legacy };
465
+ }
466
+ else if (roster?.currentModelId !== undefined) {
467
+ this.#models = { ...this.#models, currentModelId: roster.currentModelId };
468
+ }
469
+ return roster;
470
+ }
471
+
472
+ /**
473
+ * Switch the session model in place (keeps conversation context).
474
+ *
475
+ * NOTE: the CLI applies the choice GLOBALLY — every session in every process
476
+ * starts reporting the new model. Callers must hold the pool's turn lock so
477
+ * two conversations cannot interleave different models.
478
+ *
479
+ * `choice` may be a panel value (`auto`, `composer-2.5`) or a roster id; it
480
+ * is resolved against {@link modelRoster} because `set_config_option`
481
+ * accepts ONLY the parameterised ids the roster publishes. A choice the
482
+ * account does not offer is reported here, before the child is touched, so
483
+ * it cannot fail a whole turn.
484
+ *
485
+ * @param choice - a panel value or an id from {@link modelRoster}.
486
+ */
487
+ async setModel(choice) {
488
+ if (this.#agent === undefined)
489
+ throw new CursorAcpError('cursor-acp: session is not started', 'cursor/not-started');
490
+ const agent = this.#agent;
491
+ const roster = this.modelRoster;
492
+ const resolved = resolveModel(choice, roster);
493
+ if (!resolved.ok)
494
+ throw new CursorAcpError(`cursor-acp: ${resolved.error}`, 'cursor/model-unknown');
495
+ if (resolved.id === '') {
496
+ // `resolveModel` deliberately leaves an absent choice absent: there
497
+ // is no id to send, and the CLI rejects an empty `value`.
498
+ return;
499
+ }
500
+
501
+ const setConfigOption = methods.agent.session?.setConfigOption;
502
+ if (setConfigOption === undefined) {
503
+ // Older SDK table without the config-option request. That request
504
+ // speaks the CLI's own alias space, so the bracket suffix the ACP
505
+ // roster spells is stripped and the alias is passed through.
506
+ const alias = baseName(resolved.id);
507
+ try {
508
+ await agent.request('session/set_model', { sessionId: this.#sessionId, modelId: alias });
509
+ }
510
+ catch (error) {
511
+ throw new CursorAcpError(`cursor-acp: set model "${choice}" rejected: ${message(error)}`, 'cursor/model-rejected');
512
+ }
513
+ this.#models = { ...this.#models, currentModelId: alias };
514
+ this.#emit({ kind: 'model', modelId: alias });
515
+ return;
516
+ }
517
+
518
+ // With no roster there is nothing to resolve against, so the resolved id
519
+ // is a guess: if the CLI rejects it, one retry with the bare alias is
520
+ // what a build that reports no `configOptions` understands.
521
+ const attempts = [resolved.id];
522
+ if (!hasAvailableModels(roster)) {
523
+ const alias = baseName(resolved.id);
524
+ if (alias !== '' && alias !== resolved.id)
525
+ attempts.push(alias);
526
+ }
527
+
528
+ let response;
529
+ let rejection;
530
+ for (const value of attempts) {
531
+ try {
532
+ response = await agent.request(setConfigOption, { sessionId: this.#sessionId, configId: 'model', value });
533
+ rejection = undefined;
534
+ break;
535
+ }
536
+ catch (error) {
537
+ rejection = error;
538
+ }
539
+ }
540
+ if (response === undefined)
541
+ throw new CursorAcpError(`cursor-acp: set model "${choice}" rejected: ${message(rejection)}`, 'cursor/model-rejected');
542
+
543
+ const confirmed = confirmedModel(response?.configOptions) ?? this.#models?.currentModelId ?? resolved.id;
544
+ if (Array.isArray(response?.configOptions))
545
+ this.#configOptions = response.configOptions;
546
+ this.#models = { ...this.#models, currentModelId: confirmed };
547
+ this.#emit({ kind: 'model', modelId: confirmed });
548
+ }
549
+
550
+ /**
551
+ * Switch the session mode (`agent` | `plan` | `ask`).
552
+ * @param modeId - an id from {@link modes}.availableModes.
553
+ */
554
+ async setMode(modeId) {
555
+ if (this.#agent === undefined)
556
+ throw new CursorAcpError('cursor-acp: session is not started', 'cursor/not-started');
557
+ await this.#agent.request(methods.agent.session.setMode, { sessionId: this.#sessionId, modeId });
558
+ this.#emit({ kind: 'mode', modeId });
559
+ }
560
+
561
+ /**
562
+ * Send one prompt and resolve when the turn ends.
563
+ * @param text - prompt text.
564
+ * @param signal - cancellation for THIS turn.
565
+ */
566
+ async prompt(text, signal) {
567
+ signal?.throwIfAborted();
568
+ if (this.#agent === undefined)
569
+ throw new CursorAcpError('cursor-acp: session is not started', 'cursor/not-started');
570
+ if (this.#activeTurn !== undefined)
571
+ throw new CursorAcpError('cursor-acp: a turn is already running', 'cursor/turn-in-flight');
572
+
573
+ const turn = {
574
+ index: ++this.#sequence,
575
+ text: '',
576
+ reasoning: '',
577
+ calls: new Map(),
578
+ diffs: [],
579
+ usage: undefined,
580
+ stopReason: undefined,
581
+ startedAt: Date.now(),
582
+ endedAt: undefined,
583
+ };
584
+ this.#activeTurn = turn;
585
+ this.#emit({ kind: 'turn-start', index: turn.index });
586
+
587
+ const onAbort = () => {
588
+ void this.#cancel();
589
+ };
590
+ signal?.addEventListener('abort', onAbort, { once: true });
591
+
592
+ try {
593
+ const response = await this.#agent.request(methods.agent.session.prompt, {
594
+ sessionId: this.#sessionId,
595
+ prompt: [{ type: 'text', text }],
596
+ });
597
+ turn.stopReason = response?.stopReason ?? 'error';
598
+ turn.usage = response?.usage;
599
+ }
600
+ catch (error) {
601
+ turn.stopReason = 'error';
602
+ turn.error = message(error);
603
+ const failure = new CursorAcpError(`cursor-acp: prompt failed: ${message(error)}`, 'cursor/prompt-failed');
604
+ failure.partialTurn = { text: turn.text.trim(), reasoning: turn.reasoning.trim(),
605
+ diffs: turn.diffs, stopReason: signal?.aborted ? 'cancelled' : 'error' };
606
+ throw failure;
607
+ }
608
+ finally {
609
+ signal?.removeEventListener('abort', onAbort);
610
+ turn.endedAt = Date.now();
611
+ this.#activeTurn = undefined;
612
+ // The prompt flushed the transcript to the CLI's store, so this
613
+ // session can now be rehydrated by a later process.
614
+ this.#hasContent = true;
615
+ this.#emit({ kind: 'turn-end', index: turn.index, stopReason: turn.stopReason });
616
+ }
617
+
618
+ return {
619
+ index: turn.index,
620
+ text: turn.text.trim(),
621
+ reasoning: turn.reasoning.trim(),
622
+ calls: [...turn.calls.values()],
623
+ diffs: turn.diffs,
624
+ usage: turn.usage,
625
+ stopReason: turn.stopReason ?? 'error',
626
+ durationMs: (turn.endedAt ?? Date.now()) - turn.startedAt,
627
+ };
628
+ }
629
+
630
+ async #cancel() {
631
+ const agent = this.#agent;
632
+ if (agent === undefined)
633
+ return;
634
+ try {
635
+ await agent.request(methods.agent.session.cancel, { sessionId: this.#sessionId });
636
+ }
637
+ catch {
638
+ // Cancellation is best-effort; the turn's own promise settles either way.
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Translate one raw `session/update` into a normalized event.
644
+ * @param update - raw ACP update payload.
645
+ */
646
+ #ingest(update) {
647
+ const turn = this.#activeTurn;
648
+ switch (update.sessionUpdate) {
649
+ case 'user_message_chunk':
650
+ // Only ever seen during a `session/load` replay: the prompt the
651
+ // original turn was started with, replayed verbatim. It marks the
652
+ // turn boundary, so the transcript collector can split there.
653
+ this.#emit({ kind: 'prompt', text: textOf(update.content) });
654
+ break;
655
+ case 'agent_message_chunk': {
656
+ const text = textOf(update.content);
657
+ if (text === '')
658
+ break;
659
+ if (turn !== undefined)
660
+ turn.text += text;
661
+ this.#emit({ kind: 'text', text });
662
+ break;
663
+ }
664
+ case 'agent_thought_chunk': {
665
+ const text = textOf(update.content);
666
+ if (text === '')
667
+ break;
668
+ if (turn !== undefined)
669
+ turn.reasoning += text;
670
+ this.#emit({ kind: 'thought', text });
671
+ break;
672
+ }
673
+ case 'tool_call': {
674
+ const id = normalizeCallId(update.toolCallId);
675
+ const call = {
676
+ id,
677
+ name: typeof update.title === 'string' ? update.title : 'tool',
678
+ kind: typeof update.kind === 'string' ? update.kind : 'other',
679
+ status: typeof update.status === 'string' ? update.status : 'pending',
680
+ path: extractPath(update),
681
+ input: update.rawInput,
682
+ diffs: [],
683
+ };
684
+ turn?.calls.set(id, call);
685
+ this.#emit({ kind: 'tool-call', call });
686
+ break;
687
+ }
688
+ case 'tool_call_update': {
689
+ const id = normalizeCallId(update.toolCallId);
690
+ const existing = turn?.calls.get(id) ?? {
691
+ id,
692
+ name: 'tool',
693
+ kind: 'other',
694
+ status: 'pending',
695
+ path: undefined,
696
+ input: undefined,
697
+ diffs: [],
698
+ };
699
+ if (typeof update.title === 'string' && update.title !== '')
700
+ existing.name = update.title;
701
+ if (typeof update.status === 'string' && update.status !== '')
702
+ existing.status = update.status;
703
+ const path = extractPath(update);
704
+ if (path !== undefined)
705
+ existing.path = path;
706
+ if (update.rawInput !== undefined && update.rawInput !== null)
707
+ existing.input = update.rawInput;
708
+ const diffs = extractDiffs(update);
709
+ for (const diff of diffs) {
710
+ existing.diffs.push(diff);
711
+ if (turn !== undefined && !turn.diffs.some(seen => seen.path === diff.path && seen.newText === diff.newText))
712
+ turn.diffs.push(diff);
713
+ this.#emit({ kind: 'diff', diff, callId: id });
714
+ }
715
+ turn?.calls.set(id, existing);
716
+ this.#emit({ kind: 'tool-update', call: existing, diffCount: diffs.length });
717
+ break;
718
+ }
719
+ case 'plan': {
720
+ this.#emit({ kind: 'plan', entries: update.entries });
721
+ break;
722
+ }
723
+ case 'session_info_update': {
724
+ this.#emit({ kind: 'session-info', title: update.title });
725
+ break;
726
+ }
727
+ case 'current_mode_update': {
728
+ if (typeof update.currentModeId === 'string')
729
+ this.#modes = { ...this.#modes, currentModeId: update.currentModeId };
730
+ this.#emit({ kind: 'mode', modeId: update.currentModeId });
731
+ break;
732
+ }
733
+ case 'available_commands_update': {
734
+ this.#emit({ kind: 'commands', count: update.availableCommands?.length ?? 0 });
735
+ break;
736
+ }
737
+ case 'config_option_update': {
738
+ // Never emitted for model changes by this CLI build, but the
739
+ // protocol allows it, so keep the roster honest if it appears.
740
+ const options = update.configOptions;
741
+ if (!Array.isArray(options))
742
+ break;
743
+ this.#configOptions = options;
744
+ const roster = rosterFromConfigOptions(options, this.#models?.currentModelId);
745
+ if (roster?.currentModelId !== undefined)
746
+ this.#models = { ...this.#models, currentModelId: roster.currentModelId };
747
+ const current = confirmedModel(options);
748
+ if (current !== undefined)
749
+ this.#emit({ kind: 'model', modelId: current });
750
+ break;
751
+ }
752
+ default:
753
+ break;
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Publish one normalized event to the host sink.
759
+ * @param event - normalized event.
760
+ */
761
+ #emit(event) {
762
+ if (this.#transcript !== undefined) {
763
+ try {
764
+ this.#transcript(event);
765
+ }
766
+ catch {
767
+ // Same contract as the host sink: a collector bug must not stop
768
+ // the ACP stream from being read.
769
+ }
770
+ }
771
+ try {
772
+ this.#onEvent(event);
773
+ }
774
+ catch {
775
+ // A failing observer must never break the ACP stream.
776
+ }
777
+ }
778
+
779
+ /** Terminate the turn, then the process. Idempotent. */
780
+ dispose() {
781
+ if (this.#closed)
782
+ return;
783
+ this.#closed = true;
784
+ const agent = this.#agent;
785
+ if (agent !== undefined && this.#activeTurn !== undefined) {
786
+ void agent.request(methods.agent.session.cancel, { sessionId: this.#sessionId }).catch(() => { });
787
+ }
788
+ const handle = this.#handle;
789
+ if (handle !== undefined) {
790
+ const timer = setTimeout(() => {
791
+ handle.terminate();
792
+ }, CANCEL_GRACE_MS);
793
+ handle.done.then(() => {
794
+ clearTimeout(timer);
795
+ }, () => {
796
+ clearTimeout(timer);
797
+ });
798
+ }
799
+ }
800
+
801
+ /** Quiescent disposal for native run holders; synchronous callers keep dispose(). */
802
+ async disposeAndWait() {
803
+ this.dispose();
804
+ const handle = this.#handle;
805
+ if (handle === undefined) return;
806
+ // A native owner reaches this after its prompt settles or startup
807
+ // fails. Reap descendants even if the root has already exited.
808
+ await handle.terminate();
809
+ if (typeof handle.waitForExit === 'function')
810
+ await handle.waitForExit();
811
+ else
812
+ await handle.done;
813
+ }
814
+
815
+ /** Terminate immediately, skipping the cancel grace period. */
816
+ kill() {
817
+ this.#closed = true;
818
+ this.#handle?.terminate();
819
+ }
820
+ }
821
+
822
+ /**
823
+ * The `model` value a `configOptions` array reports as current, or `undefined`.
824
+ *
825
+ * A `set_config_option` response confirms the switch this way, and a
826
+ * `config_option_update` notification announces one this way.
827
+ *
828
+ * @param configOptions - the `configOptions` array of a response or notification.
829
+ */
830
+ function confirmedModel(configOptions) {
831
+ if (!Array.isArray(configOptions))
832
+ return undefined;
833
+ for (const option of configOptions) {
834
+ if (option !== null && typeof option === 'object' && option.id === 'model'
835
+ && typeof option.currentValue === 'string' && option.currentValue !== '')
836
+ return option.currentValue;
837
+ }
838
+ return undefined;
839
+ }
840
+
841
+ /**
842
+ * Whether a roster names any model at all.
843
+ *
844
+ * Without published ids a choice cannot be resolved, so the caller is allowed
845
+ * one retry in the CLI's own alias space.
846
+ *
847
+ * @param roster - `{currentModelId, availableModels}`, or `undefined`.
848
+ */
849
+ function hasAvailableModels(roster) {
850
+ return Array.isArray(roster?.availableModels) && roster.availableModels.length > 0;
851
+ }
852
+
853
+ /**
854
+ * Extract text from an ACP content part.
855
+ * @param content - `{type:'text', text}` or other part.
856
+ */
857
+ function textOf(content) {
858
+ if (content === null || typeof content !== 'object')
859
+ return '';
860
+ return typeof content.text === 'string' ? content.text : '';
861
+ }
862
+
863
+ /**
864
+ * Render an unknown thrown value as a message string.
865
+ * @param error - thrown value.
866
+ */
867
+ function message(error) {
868
+ if (error instanceof Error)
869
+ return error.message;
870
+ return String(error);
871
+ }