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/lib/mirror.js ADDED
@@ -0,0 +1,1293 @@
1
+ /**
2
+ * dsh-cursor-dev — mirror a Cursor run into a real DSH child conversation.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * The parent session header lists *child conversations* (子智能体), and clicking
6
+ * one opens that child's own transcript. The DSH runtime only publishes such a
7
+ * row when the provider hands back a live `localAgent` whose session really
8
+ * exists (`SubagentRuntime.start`: `const child = run.localAgent?.session; if
9
+ * (child !== void 0) establishCatalogChild(...)`). The stock ACP backend returns
10
+ * `localAgent: undefined`, which is exactly why a Cursor run has always been
11
+ * invisible here.
12
+ *
13
+ * So this module does two things:
14
+ *
15
+ * 1. `openCursorChild()` creates a REAL child session + agent through
16
+ * `ctx.agents.create` — persisted, lineage-tagged (`origin: 'subagent'`,
17
+ * `parentSession`, `delegationDepth`), carrying a `subagent/descriptor` so the
18
+ * client labels the row instead of falling back to the raw id.
19
+ * 2. `CursorMirror` writes what Cursor actually did into that child's log —
20
+ * `turn/start`, `step/start`, the task as a `user/message`, one
21
+ * `tool/call` + `tool/result` pair per Cursor tool step, and finally the
22
+ * `assistant/message` with its reasoning and answer, then `turn/end`.
23
+ *
24
+ * Everything is built locally rather than imported from `@deepseek-ai/*`: a
25
+ * profile-installed plugin resolves those packages only inside the app's own
26
+ * tree, so importing them would break the repo's test run. The shapes are pinned
27
+ * by `Session.append`'s validator, and the smoke tests exercise them against a
28
+ * fake session.
29
+ *
30
+ * SURFACE RULES (getting these wrong throws inside `Session.append`):
31
+ * `user/message`, `assistant/message` and `tool/result` are surface-eligible and
32
+ * MUST carry `{surfaceOp: 'append'}`; every other type here must carry NO
33
+ * surface metadata at all. `tool/result` also cites the `tool/call` seq it
34
+ * answers through `sourceEventSeqs`, which is how the UI pairs a result with its
35
+ * call card.
36
+ *
37
+ * @module dsh-cursor-dev/mirror
38
+ */
39
+ import { randomUUID } from 'node:crypto';
40
+
41
+ /** Descriptor version the client folds; a mismatch makes the row fall back to the id. */
42
+ export const DESCRIPTOR_VERSION = 3;
43
+
44
+ /** Provider name this plugin registers with the DSH subagent runtime. */
45
+ export const PROVIDER_NAME = 'cursor';
46
+
47
+ /**
48
+ * The delegation-scope statement every in-process DSH child carries.
49
+ *
50
+ * A runtime-context contribution rather than a system-prompt section, so the
51
+ * deployment's own system prompt stays uniform across parents and children.
52
+ */
53
+ export const SUBAGENT_DELEGATION_CONTEXT = 'You are a delegated subagent: your permission scope was fixed when you were '
54
+ + 'started and cannot be widened from inside this session — operations that require approval are rejected automatically. '
55
+ + 'When the task needs access beyond that scope, do not retry the denied operation; state the '
56
+ + 'limitation in your reply so the delegating agent can handle it.';
57
+
58
+ /** Longest tool-result body written into a child conversation. */
59
+ const MAX_TOOL_TEXT = 1200;
60
+
61
+ /**
62
+ * How many child conversations stay live at once.
63
+ *
64
+ * A child is a real DSH agent, and the parent's header keeps listing every one
65
+ * that was ever opened (their logs are durable), so the live set is capped and
66
+ * the least recently used are closed — the next run on that conversation simply
67
+ * reopens it.
68
+ */
69
+ const MAX_CHILDREN = 8;
70
+
71
+ /** Freeze a message the way the session log expects to receive it. */
72
+ export function deepFreeze(value) {
73
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
74
+ Object.freeze(value);
75
+ for (const key of Object.keys(value))
76
+ deepFreeze(value[key]);
77
+ }
78
+ return value;
79
+ }
80
+
81
+ /**
82
+ * One identified, immutable user message.
83
+ *
84
+ * `createUserMessage` lives in a first-party package this plugin cannot resolve
85
+ * from a profile install, and all it does is stamp an id, a role, and a freeze
86
+ * onto the input — so the shape is built here instead.
87
+ *
88
+ * @param content - message content blocks.
89
+ * @param source - producer source; defaults to the human user.
90
+ * @returns a frozen user message.
91
+ */
92
+ export function userMessage(content, source = { kind: 'user' }) {
93
+ return deepFreeze({ id: randomUUID(), role: 'user', content, source });
94
+ }
95
+
96
+ /**
97
+ * One identified, immutable assistant message.
98
+ *
99
+ * @param options - `content`, `provider`, `model`.
100
+ * @returns a frozen assistant message.
101
+ */
102
+ export function assistantMessage(options) {
103
+ return deepFreeze({
104
+ id: randomUUID(),
105
+ role: 'assistant',
106
+ content: options.content,
107
+ source: {
108
+ kind: 'model',
109
+ provider: options.provider ?? PROVIDER_NAME,
110
+ model: options.model ?? PROVIDER_NAME,
111
+ },
112
+ });
113
+ }
114
+
115
+ /**
116
+ * One identified, immutable tool-role message answering a tool call.
117
+ *
118
+ * @param options - `callId`, `content`, `isError`.
119
+ * @returns a frozen tool result message.
120
+ */
121
+ export function toolResultMessage(options) {
122
+ return deepFreeze({
123
+ id: randomUUID(),
124
+ role: 'tool',
125
+ content: options.content,
126
+ source: { kind: 'tool', callId: options.callId },
127
+ toolCallId: options.callId,
128
+ isError: options.isError === true,
129
+ });
130
+ }
131
+
132
+ /**
133
+ * The child's delegation depth.
134
+ *
135
+ * Mirrors the runtime helper: the durable header wins over the runtime option,
136
+ * and the larger of the two is the truth.
137
+ *
138
+ * @param agent - the parent agent.
139
+ * @returns its delegation depth.
140
+ */
141
+ export function delegationDepthOf(agent) {
142
+ const runtime = agent?.options?.subagentDepth;
143
+ const header = agent?.session?.header?.delegationDepth;
144
+ const left = Number.isSafeInteger(header) && header > 0 ? header : 0;
145
+ const right = Number.isSafeInteger(runtime) && runtime > 0 ? runtime : 0;
146
+ return Math.max(left, right);
147
+ }
148
+
149
+ /**
150
+ * Refuse a child depth that an explicit cap forbids.
151
+ *
152
+ * The cap is the recursion budget the operator allows: `1` means only a
153
+ * top-level agent (depth 0) may create a child, `0` forbids children entirely.
154
+ * `undefined` leaves the budget to whoever configured the call.
155
+ *
156
+ * @param childDepth - the resolved depth the child would get.
157
+ * @param maxDepth - optional cap.
158
+ * @returns `childDepth` when it fits, so callers can use it inline.
159
+ */
160
+ export function assertChildDepth(childDepth, maxDepth) {
161
+ if (!Number.isSafeInteger(childDepth))
162
+ throw new RangeError('dsh-cursor-dev: subagent child depth exceeds the safe-integer range');
163
+ if (typeof maxDepth === 'number' && childDepth > maxDepth)
164
+ throw new RangeError(`dsh-cursor-dev: subagent depth ${childDepth} exceeds maxDepth ${maxDepth}`);
165
+ return childDepth;
166
+ }
167
+
168
+ /**
169
+ * Resolve the child depth, refusing to exceed an explicit cap.
170
+ *
171
+ * @param parent - the delegating agent.
172
+ * @param maxDepth - optional recursion cap.
173
+ * @returns the child's delegation depth.
174
+ */
175
+ export function resolveChildDepth(parent, maxDepth) {
176
+ return assertChildDepth(delegationDepthOf(parent) + 1, maxDepth);
177
+ }
178
+
179
+ /**
180
+ * The parent's provider/model/reasoning effort as the child should inherit it.
181
+ *
182
+ * The latest request header owns the route after request-time selection;
183
+ * creation options are the fallback before the first request.
184
+ *
185
+ * @param parent - the delegating agent.
186
+ * @returns detached agent options to merge from.
187
+ */
188
+ export function parentAgentOptionsForDelegation(parent) {
189
+ const requestConfig = parent?.session?.requestHeader?.()?.config;
190
+ const created = parent?.options ?? {};
191
+ if (requestConfig === undefined)
192
+ return { ...created };
193
+ const { provider: _p, model: _m, reasoningEffort: _r, ...rest } = created;
194
+ return {
195
+ ...rest,
196
+ provider: requestConfig.provider,
197
+ model: requestConfig.model,
198
+ ...requestConfig.reasoningEffort === undefined ? {} : { reasoningEffort: requestConfig.reasoningEffort },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Resolve the child's `AgentOptions`.
204
+ *
205
+ * The child never runs the DSH agent loop — Cursor is the loop — so these
206
+ * values only describe the child's lineage and give a future reader a sane
207
+ * route. They are inherited rather than invented so the route stays one the LLM
208
+ * runtime can actually resolve.
209
+ *
210
+ * @param parent - the delegating agent.
211
+ * @param requested - per-child overrides, if any.
212
+ * @param childDepth - the resolved delegation depth to stamp.
213
+ * @returns options for `ctx.agents.create()`.
214
+ */
215
+ export function resolveChildAgentOptions(parent, requested, childDepth) {
216
+ const parentOptions = parentAgentOptionsForDelegation(parent);
217
+ const resolved = {
218
+ ...parentOptions.provider === undefined ? {} : { provider: parentOptions.provider },
219
+ ...parentOptions.model === undefined ? {} : { model: parentOptions.model },
220
+ ...parentOptions.reasoningEffort === undefined ? {} : { reasoningEffort: parentOptions.reasoningEffort },
221
+ ...parentOptions.maxTokens === undefined ? {} : { maxTokens: parentOptions.maxTokens },
222
+ ...requested,
223
+ subagentDepth: childDepth,
224
+ };
225
+ const routeChanged = resolved.provider !== parentOptions.provider || resolved.model !== parentOptions.model;
226
+ if (routeChanged && requested?.reasoningEffort === undefined)
227
+ delete resolved.reasoningEffort;
228
+ return resolved;
229
+ }
230
+
231
+ /**
232
+ * Build the child session's durable creation metadata.
233
+ *
234
+ * @param parent - the delegating agent.
235
+ * @param childDepth - the resolved delegation depth.
236
+ * @param isSeeded - whether the child log starts from inherited events.
237
+ * @returns the `meta` object for `ctx.agents.create()`.
238
+ */
239
+ export function childSessionMeta(parent, childDepth, isSeeded) {
240
+ const parentHeader = parent?.session?.header ?? {};
241
+ const preset = parent?.ctx?.get?.('agentPresets')?.composedPreset?.(parent.ctx);
242
+ return {
243
+ ...parentHeader.cwd === undefined ? {} : { cwd: parentHeader.cwd },
244
+ ...preset === undefined ? {} : { agentPreset: preset },
245
+ parentSession: parentHeader.id,
246
+ isSeeded: isSeeded === true,
247
+ // Navigation classification only; the descriptor stays the authority
248
+ // for mode and continuation capability.
249
+ origin: 'subagent',
250
+ // Durable: the recursion budget must survive persistence and resume.
251
+ delegationDepth: childDepth,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Capture the parent's effective policy so the child's log can be read alone.
257
+ *
258
+ * @param parent - the delegating agent.
259
+ * @returns `{permissionPreset?, sandboxMode?, approvalPolicy?}`.
260
+ */
261
+ export function captureDelegatedPolicyOverrides(parent) {
262
+ const ctx = parent?.ctx;
263
+ const preset = ctx?.get?.('permissionPresets')?.current?.(parent.session);
264
+ const sandboxMode = ctx?.get?.('sandboxPolicy')?.overrideOf?.(parent.session);
265
+ const approval = ctx?.get?.('approval');
266
+ return {
267
+ permissionPreset: preset === 'auto' || preset === 'danger-full-access' ? preset : undefined,
268
+ sandboxMode,
269
+ approvalPolicy: approval === undefined ? undefined : 'never',
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Append the captured policy to the child's own log.
275
+ *
276
+ * `permission/preset` deliberately carries no `source` field — the event's
277
+ * schema has no such key, and an extra key is what makes a later reload fail.
278
+ *
279
+ * @param session - the unpublished child session.
280
+ * @param overrides - the policy captured at delegation.
281
+ */
282
+ export function appendDelegatedPolicyOverrides(session, overrides) {
283
+ if (overrides?.sandboxMode !== undefined)
284
+ session.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' });
285
+ if (overrides?.approvalPolicy !== undefined)
286
+ session.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' });
287
+ if (overrides?.permissionPreset !== undefined)
288
+ session.append('permission/preset', { preset: overrides.permissionPreset });
289
+ }
290
+
291
+ /**
292
+ * Compose the child inside its creation window.
293
+ *
294
+ * Every step is optional and guarded: the child is driven by Cursor, not by the
295
+ * DSH agent loop, so a composition that cannot be applied must not cost the
296
+ * user the run itself.
297
+ *
298
+ * @param childCtx - the unpublished child's scoped context.
299
+ * @param parent - the delegating agent.
300
+ * @param composition - `persona` and `toolFilter` requested by the caller.
301
+ * @param logger - optional warning sink.
302
+ */
303
+ export function applyChildComposition(childCtx, parent, composition = {}, logger) {
304
+ const warn = (step, error) => {
305
+ logger?.warn?.(`dsh-cursor-dev: child ${step} was not applied: ${error instanceof Error ? error.message : String(error)}`);
306
+ };
307
+ try {
308
+ childCtx?.get?.('agentPresets')?.composeFrom?.(childCtx, parent?.ctx);
309
+ }
310
+ catch (error) {
311
+ warn('preset', error);
312
+ }
313
+ const prompt = childCtx?.get?.('systemPrompt');
314
+ if (prompt !== undefined && typeof prompt.context === 'function') {
315
+ try {
316
+ prompt.context({
317
+ name: 'subagent:delegation',
318
+ order: prompt.getContextOrder?.('SUBAGENT_DELEGATION'),
319
+ text: SUBAGENT_DELEGATION_CONTEXT,
320
+ });
321
+ }
322
+ catch (error) {
323
+ warn('delegation context', error);
324
+ }
325
+ }
326
+ if (composition.persona !== undefined && typeof prompt?.section === 'function') {
327
+ try {
328
+ prompt.section({
329
+ name: 'deployment:persona-prefix',
330
+ order: prompt.getSectionOrder?.('DEPLOYMENT_PERSONA_PREFIX'),
331
+ text: composition.persona,
332
+ });
333
+ }
334
+ catch (error) {
335
+ warn('persona', error);
336
+ }
337
+ }
338
+ if (composition.toolFilter !== undefined) {
339
+ try {
340
+ childCtx?.tools?.restrict?.(composition.toolFilter);
341
+ }
342
+ catch (error) {
343
+ warn('tool filter', error);
344
+ }
345
+ }
346
+ }
347
+
348
+ /**
349
+ * The descriptor that tells the client what kind of child this is.
350
+ *
351
+ * Without it the catalog row shows the raw session id and a generic activity
352
+ * state instead of the label and a completed/failed result.
353
+ *
354
+ * @param options - `label` for the row.
355
+ * @returns a frozen descriptor payload.
356
+ */
357
+ export function cursorDescriptor(options = {}) {
358
+ const label = typeof options.label === 'string' && options.label !== '' ? options.label : undefined;
359
+ return deepFreeze({
360
+ version: DESCRIPTOR_VERSION,
361
+ mode: 'one-shot',
362
+ provider: PROVIDER_NAME,
363
+ ...label === undefined ? {} : { label },
364
+ });
365
+ }
366
+
367
+ /**
368
+ * The parent-owned catalog entry that makes the child navigable.
369
+ *
370
+ * The subagent runtime appends this itself when a provider goes through
371
+ * `ctx.subagents.start`; a run started straight from the tool has to append it
372
+ * here, because nothing else tells the parent's header that the child exists.
373
+ *
374
+ * @param options - `childId`, `childCreatedAt`, `label`.
375
+ * @returns the `subagent/catalog` payload.
376
+ */
377
+ export function catalogPayload(options) {
378
+ const label = typeof options.label === 'string' && options.label !== '' ? options.label : undefined;
379
+ return {
380
+ version: 0,
381
+ childId: options.childId,
382
+ childCreatedAt: options.childCreatedAt,
383
+ mode: 'one-shot',
384
+ ...label === undefined ? {} : { label },
385
+ };
386
+ }
387
+
388
+ /** Clamp one tool-result body so a huge write cannot bloat the child log. */
389
+ function clamp(text) {
390
+ const value = String(text ?? '');
391
+ if (value.length <= MAX_TOOL_TEXT)
392
+ return value;
393
+ return `${value.slice(0, MAX_TOOL_TEXT)}\n… (${value.length - MAX_TOOL_TEXT} more characters)`;
394
+ }
395
+
396
+ /** Count the lines one diff side adds or removes, for the result caption. */
397
+ function diffCounts(diffs) {
398
+ let added = 0;
399
+ let removed = 0;
400
+ for (const diff of diffs) {
401
+ const before = typeof diff.oldText === 'string' ? diff.oldText.split('\n') : [];
402
+ const after = typeof diff.newText === 'string' ? diff.newText.split('\n') : [];
403
+ const beforeSet = new Set(before);
404
+ const afterSet = new Set(after);
405
+ added += after.filter(line => !beforeSet.has(line)).length;
406
+ removed += before.filter(line => !afterSet.has(line)).length;
407
+ }
408
+ return { added, removed };
409
+ }
410
+
411
+ /**
412
+ * Write one Cursor run into a child session as a complete DSH turn.
413
+ *
414
+ * Cursor's own loop is the producer, so the mirror stays a faithful *record*:
415
+ * one turn, one step, the task, one tool card per Cursor tool step, then the
416
+ * reasoning and the final answer. Nothing here is model-visible inside the child
417
+ * (a one-shot child opens read-only), which is what keeps the transcript honest
418
+ * without inventing a second agent loop.
419
+ */
420
+ export class CursorMirror {
421
+ #session;
422
+
423
+ #turn = 0;
424
+
425
+ #step = 1;
426
+
427
+ #calls = new Map();
428
+
429
+ #open = false;
430
+
431
+ #text = '';
432
+
433
+ #reasoning = '';
434
+
435
+ #model;
436
+
437
+ /**
438
+ * @param session - the child session to write into.
439
+ * @param model - model name stamped on this mirror's assistant messages.
440
+ */
441
+ constructor(session, model) {
442
+ this.#session = session;
443
+ this.#model = typeof model === 'string' && model !== '' ? model : PROVIDER_NAME;
444
+ // A resumed child already owns turns; continuing at 1 would publish a
445
+ // second "turn 1" into the same conversation and make the transcript
446
+ // read as if the earlier work had been undone.
447
+ this.#turn = highestTurn(session);
448
+ }
449
+
450
+ /** Whether a turn is currently open. */
451
+ get open() {
452
+ return this.#open;
453
+ }
454
+
455
+ /**
456
+ * Name the model this mirror's assistant rows carry.
457
+ *
458
+ * A mirror is built before the run resolves its model, so the name can only
459
+ * be supplied here. Every row the mirror writes — the tool-call
460
+ * announcements as well as the closing answer — is stamped with it, so a
461
+ * transcript read later names the model it actually ran with.
462
+ *
463
+ * @param model - the model id, ignored when empty.
464
+ */
465
+ useModel(model) {
466
+ if (typeof model === 'string' && model !== '')
467
+ this.#model = model;
468
+ }
469
+
470
+ /** How many tool cards this mirror has written. */
471
+ get calls() {
472
+ return this.#calls.size;
473
+ }
474
+
475
+ /** The turn number this mirror is writing (the last one when idle). */
476
+ get turn() {
477
+ return this.#turn;
478
+ }
479
+
480
+ /**
481
+ * Open the mirrored turn and record the task as the child's user message.
482
+ *
483
+ * @param prompt - the prompt Cursor was given.
484
+ */
485
+ begin(prompt) {
486
+ if (this.#open)
487
+ this.finish({});
488
+ this.#turn += 1;
489
+ this.#step = 1;
490
+ this.#calls.clear();
491
+ this.#text = '';
492
+ this.#reasoning = '';
493
+ const session = this.#session;
494
+ session.append('turn/start', { turn: this.#turn });
495
+ session.append('step/start', { turn: this.#turn, step: this.#step });
496
+ session.append('user/message', userMessage([{ type: 'text', text: String(prompt ?? '') }]), { surfaceOp: 'append' });
497
+ this.#open = true;
498
+ }
499
+
500
+ /**
501
+ * Fold one normalized Cursor event into the child conversation.
502
+ *
503
+ * @param event - event emitted by `CursorSession`.
504
+ */
505
+ observe(event) {
506
+ if (!this.#open || event === null || typeof event !== 'object')
507
+ return;
508
+ switch (event.kind) {
509
+ case 'tool-call':
510
+ this.#openCall(event.call);
511
+ break;
512
+ case 'tool-update':
513
+ this.#updateCall(event.call);
514
+ break;
515
+ case 'diff':
516
+ this.#addDiff(event.callId, event.diff);
517
+ break;
518
+ case 'text':
519
+ this.#text += typeof event.text === 'string' ? event.text : '';
520
+ break;
521
+ case 'thought':
522
+ this.#reasoning += typeof event.text === 'string' ? event.text : '';
523
+ break;
524
+ default:
525
+ break;
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Close the mirrored turn with the final answer and its outcome.
531
+ *
532
+ * @param outcome - `text`, `reasoning`, `ok`, `stopReason`, `error`, `model`.
533
+ */
534
+ finish(outcome = {}) {
535
+ if (!this.#open)
536
+ return;
537
+ const session = this.#session;
538
+ for (const [callId, entry] of this.#calls) {
539
+ if (!entry.done)
540
+ this.#writeResult(callId, '(Cursor 没有报告这一步的结果 · no result reported)', true);
541
+ }
542
+ const text = typeof outcome.text === 'string' && outcome.text !== '' ? outcome.text : this.#text.trim();
543
+ const reasoning = typeof outcome.reasoning === 'string' && outcome.reasoning !== ''
544
+ ? outcome.reasoning
545
+ : this.#reasoning.trim();
546
+ const content = [];
547
+ if (reasoning !== '')
548
+ content.push({ type: 'reasoning', text: reasoning });
549
+ if (text !== '')
550
+ content.push({ type: 'text', text });
551
+ if (content.length === 0) {
552
+ content.push({
553
+ type: 'text',
554
+ text: outcome.ok === true
555
+ ? '(Cursor 没有留下文字说明 · no written summary)'
556
+ : `(运行失败 · run failed: ${outcome.error ?? outcome.stopReason ?? 'unknown'})`,
557
+ });
558
+ }
559
+ session.append('assistant/message', {
560
+ turn: this.#turn,
561
+ step: this.#step,
562
+ message: assistantMessage({
563
+ content,
564
+ provider: PROVIDER_NAME,
565
+ model: typeof outcome.model === 'string' && outcome.model !== '' ? outcome.model : PROVIDER_NAME,
566
+ }),
567
+ stream: [],
568
+ }, { surfaceOp: 'append' });
569
+ session.append('step/end', { turn: this.#turn, step: this.#step });
570
+ session.append('turn/end', { turn: this.#turn, reason: turnEndReason(outcome) });
571
+ this.#open = false;
572
+ }
573
+
574
+ /**
575
+ * Advertise one tool call before its `tool/call` row.
576
+ *
577
+ * A v4 log only accepts a `tool/call` that an earlier `assistant/message`
578
+ * announced with a `tool-call` content block, and the two must agree on the
579
+ * name and the arguments string exactly — otherwise loading the conversation
580
+ * fails with `tool/call <id> has no advertised tool lifecycle`. The real agent
581
+ * loop writes this row, so the mirror writes it too.
582
+ *
583
+ * @param callId - the call's id.
584
+ * @param name - the tool name the `tool/call` row will carry.
585
+ * @param args - the exact arguments string the `tool/call` row will carry.
586
+ */
587
+ #announce(callId, name, args) {
588
+ this.#session.append('assistant/message', {
589
+ turn: this.#turn,
590
+ step: this.#step,
591
+ message: assistantMessage({
592
+ content: [{ type: 'tool-call', id: callId, name, arguments: args }],
593
+ provider: PROVIDER_NAME,
594
+ model: this.#model,
595
+ }),
596
+ stream: [],
597
+ }, { surfaceOp: 'append' });
598
+ }
599
+
600
+ /** Append a tool card for a Cursor tool step, once per call id. */
601
+ #openCall(call) {
602
+ if (call === null || typeof call !== 'object')
603
+ return;
604
+ const callId = typeof call.id === 'string' && call.id !== '' ? call.id : `call-${this.#calls.size + 1}`;
605
+ if (this.#calls.has(callId)) {
606
+ this.#updateCall(call);
607
+ return;
608
+ }
609
+ const name = typeof call.name === 'string' && call.name !== '' ? call.name : 'cursor tool';
610
+ const args = JSON.stringify(call.input ?? {});
611
+ const entry = { seq: undefined, done: false, diffs: [], status: call.status, path: call.path, name };
612
+ // Advertise first: the `tool/call` row below is only admissible against an
613
+ // already-announced call, and both rows must carry the same two strings.
614
+ this.#announce(callId, name, args);
615
+ entry.seq = this.#session.append('tool/call', {
616
+ turn: this.#turn,
617
+ step: this.#step,
618
+ callId,
619
+ name,
620
+ arguments: args,
621
+ }).seq;
622
+ this.#calls.set(callId, entry);
623
+ if (isTerminal(call.status))
624
+ this.#writeResult(callId, this.#resultText(entry), call.status === 'failed');
625
+ }
626
+
627
+ /** Fold a `tool_call_update` into the open card. */
628
+ #updateCall(call) {
629
+ if (call === null || typeof call !== 'object' || typeof call.id !== 'string')
630
+ return;
631
+ const entry = this.#calls.get(call.id);
632
+ if (entry === undefined) {
633
+ this.#openCall(call);
634
+ return;
635
+ }
636
+ if (typeof call.status === 'string' && call.status !== '')
637
+ entry.status = call.status;
638
+ if (typeof call.path === 'string' && call.path !== '')
639
+ entry.path = call.path;
640
+ if (isTerminal(entry.status) && !entry.done)
641
+ this.#writeResult(call.id, this.#resultText(entry), entry.status === 'failed');
642
+ }
643
+
644
+ /** Attach one observed file diff to its call card. */
645
+ #addDiff(callId, diff) {
646
+ if (diff === null || typeof diff !== 'object')
647
+ return;
648
+ const entry = this.#calls.get(callId);
649
+ if (entry === undefined || entry.done)
650
+ return;
651
+ entry.diffs.push(diff);
652
+ }
653
+
654
+ /** The text one tool card shows as its result. */
655
+ #resultText(entry) {
656
+ const counts = diffCounts(entry.diffs);
657
+ const delta = counts.added === 0 && counts.removed === 0 ? '' : ` (+${counts.added}/-${counts.removed})`;
658
+ const where = entry.path === undefined ? '' : ` ${entry.path}`;
659
+ const lines = [`${entry.status ?? 'completed'}${where}${delta}`];
660
+ for (const diff of entry.diffs) {
661
+ const body = typeof diff.newText === 'string' ? diff.newText : '';
662
+ if (body !== '')
663
+ lines.push('', clamp(body));
664
+ }
665
+ return lines.join('\n');
666
+ }
667
+
668
+ /** Append the result that answers one tool call. */
669
+ #writeResult(callId, text, isError) {
670
+ const entry = this.#calls.get(callId);
671
+ if (entry === undefined || entry.done)
672
+ return;
673
+ entry.done = true;
674
+ this.#session.append('tool/result', {
675
+ turn: this.#turn,
676
+ step: this.#step,
677
+ message: toolResultMessage({ callId, content: [{ type: 'text', text: clamp(text) }], isError }),
678
+ }, { surfaceOp: 'append', sourceEventSeqs: [entry.seq] });
679
+ }
680
+ }
681
+
682
+ /** ACP statuses that mean the call will not change again. */
683
+ function isTerminal(status) {
684
+ return status === 'completed' || status === 'failed' || status === 'cancelled';
685
+ }
686
+
687
+ /**
688
+ * The last turn number already present in a session log.
689
+ *
690
+ * `ctx.agents.resume` reopens an existing log, and the mirror has to continue
691
+ * that numbering rather than restart it.
692
+ *
693
+ * @param session - the child session.
694
+ * @returns the highest `turn/start` turn, or 0.
695
+ */
696
+ export function highestTurn(session) {
697
+ try {
698
+ const events = session?.snapshotEvents?.() ?? session?.ownEvents?.() ?? [];
699
+ let highest = 0;
700
+ for (const event of events) {
701
+ const turn = event?.data?.turn;
702
+ if (event?.type === 'turn/start' && Number.isSafeInteger(turn) && turn > highest)
703
+ highest = turn;
704
+ }
705
+ return highest;
706
+ }
707
+ catch {
708
+ return 0;
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Translate a Cursor outcome into the session's turn-end reason.
714
+ *
715
+ * @param outcome - the mirror outcome.
716
+ * @returns a `TurnEndReason`.
717
+ */
718
+ export function turnEndReason(outcome = {}) {
719
+ if (outcome.ok === true)
720
+ return { kind: 'completed' };
721
+ const stopReason = typeof outcome.stopReason === 'string' ? outcome.stopReason : '';
722
+ if (stopReason === 'cancelled' || outcome.cancelled === true)
723
+ return { kind: 'aborted', reason: { kind: 'parent' } };
724
+ if (stopReason === 'max_tokens')
725
+ return { kind: 'max-tokens' };
726
+ return {
727
+ kind: 'error',
728
+ error: {
729
+ message: typeof outcome.error === 'string' && outcome.error !== '' ? outcome.error : `cursor run ended with ${stopReason || 'no result'}`,
730
+ code: 'cursor/run-failed',
731
+ },
732
+ };
733
+ }
734
+
735
+ /**
736
+ * Split a replayed transcript into the turns the user actually asked for.
737
+ *
738
+ * A `session/load` replay has no turn events at all: the only structure in it is
739
+ * the `user_message_chunk` the CLI emits before each turn's work. That chunk is
740
+ * therefore the boundary — but the CLI may also split ONE prompt across several
741
+ * chunks, so a chunk only opens a new turn once the current one has produced
742
+ * something. Events that arrive before the first prompt still get a turn, so
743
+ * nothing can be silently dropped from the record.
744
+ *
745
+ * @param events - normalized events collected by `SessionPool.replay()`.
746
+ * @returns `{prompt, events}` per turn, in replay order.
747
+ */
748
+ export function foldTranscript(events) {
749
+ const turns = [];
750
+ let current;
751
+ for (const event of events) {
752
+ if (event?.kind === 'prompt') {
753
+ if (current === undefined || current.events.length > 0) {
754
+ current = { prompt: '', events: [] };
755
+ turns.push(current);
756
+ }
757
+ current.prompt += typeof event.text === 'string' ? event.text : '';
758
+ continue;
759
+ }
760
+ if (current === undefined) {
761
+ current = { prompt: '', events: [] };
762
+ turns.push(current);
763
+ }
764
+ current.events.push(event);
765
+ }
766
+ return turns;
767
+ }
768
+
769
+ /**
770
+ * Write a replayed transcript into a child conversation.
771
+ *
772
+ * Each replayed turn becomes a complete mirrored turn — the prompt as the user
773
+ * message, one tool card per Cursor step, the reasoning and the answer — which
774
+ * is exactly the shape a live run produces. Reading history back therefore
775
+ * yields a transcript the client renders with no special cases.
776
+ *
777
+ * @param mirror - the child's `CursorMirror`.
778
+ * @param events - normalized events collected by `SessionPool.replay()`.
779
+ * @param options - `model` recorded on the assistant messages.
780
+ * @returns how many turns were written.
781
+ */
782
+ export function mirrorTranscript(mirror, events, options = {}) {
783
+ // The announcements written while replaying carry this name too, so the
784
+ // whole transcript agrees on the model rather than naming the provider in
785
+ // some rows and the real model in others.
786
+ mirror.useModel(options.model);
787
+ const turns = foldTranscript(events);
788
+ for (const turn of turns) {
789
+ mirror.begin(turn.prompt);
790
+ for (const event of turn.events)
791
+ mirror.observe(event);
792
+ mirror.finish({ ok: true, model: options.model });
793
+ }
794
+ return turns.length;
795
+ }
796
+
797
+ /**
798
+ * Create a real, navigable DSH child conversation for one Cursor run.
799
+ *
800
+ * Returns `undefined` when the host cannot provide one (no `agents` service, or
801
+ * a parent with no session): mirroring is a visibility feature, never a
802
+ * precondition for the coding work itself.
803
+ *
804
+ * @param options - `agents`, `parent`, `label`, `announce`, `descriptor`, `logger`, `maxChildDepth`.
805
+ * @returns `{child, handle, mirror, dispose}` or undefined.
806
+ */
807
+ export async function openCursorChild(options) {
808
+ const { agents, parent, logger } = options;
809
+ if (agents === undefined || typeof agents.create !== 'function')
810
+ return undefined;
811
+ const parentSession = parent?.session;
812
+ if (parentSession === undefined || typeof parentSession.append !== 'function')
813
+ return undefined;
814
+
815
+ const label = typeof options.label === 'string' && options.label.trim() !== '' ? options.label.trim() : undefined;
816
+ const childDepth = resolveChildDepth(parent, options.maxChildDepth);
817
+ const childId = `session-${randomUUID()}`;
818
+ const handle = await agents.create({
819
+ sessionId: childId,
820
+ parentAgent: parent,
821
+ meta: childSessionMeta(parent, childDepth, false),
822
+ agentOptions: resolveChildAgentOptions(parent, undefined, childDepth),
823
+ setup: (childCtx, child) => {
824
+ composeChild(childCtx, child, parent, { logger });
825
+ child.session.append('subagent/descriptor', options.descriptor ?? cursorDescriptor({ label }));
826
+ },
827
+ });
828
+ const child = handle.agent;
829
+ if (options.announce !== false)
830
+ announceChild(parentSession, child, label, logger);
831
+ return {
832
+ child,
833
+ handle,
834
+ mirror: new CursorMirror(child.session, options.model),
835
+ dispose: async () => {
836
+ await handle.dispose();
837
+ },
838
+ };
839
+ }
840
+
841
+ /**
842
+ * Reopen the child conversation a previous run created.
843
+ *
844
+ * A Cursor conversation survives its process: the durable record remembers both
845
+ * ids, so a later run must continue the SAME child log. Creating a second child
846
+ * would split one piece of work across two rows in the parent's header and hide
847
+ * everything the first run did.
848
+ *
849
+ * @param options - `agents`, `parent`, `childSessionId`, `logger`.
850
+ * @returns `{child, handle, mirror, dispose}` or undefined when it cannot reopen.
851
+ */
852
+ export async function resumeCursorChild(options) {
853
+ const { agents, parent, childSessionId, logger } = options;
854
+ if (agents === undefined || typeof agents.resume !== 'function')
855
+ return undefined;
856
+ if (typeof childSessionId !== 'string' || childSessionId === '')
857
+ return undefined;
858
+ const childDepth = resolveChildDepth(parent, options.maxChildDepth);
859
+ const handle = await agents.resume({
860
+ resumeSessionId: childSessionId,
861
+ parentAgent: parent,
862
+ agentOptions: resolveChildAgentOptions(parent, undefined, childDepth),
863
+ setup: (childCtx, child) => {
864
+ composeChild(childCtx, child, parent, { logger });
865
+ },
866
+ });
867
+ const child = handle.agent;
868
+ // The row already exists in the parent's log from the first run; a second
869
+ // `subagent/catalog` entry would list the same conversation twice.
870
+ return {
871
+ child,
872
+ handle,
873
+ mirror: new CursorMirror(child.session, options.model),
874
+ dispose: async () => {
875
+ await handle.dispose();
876
+ },
877
+ };
878
+ }
879
+
880
+ /**
881
+ * Open a child conversation for a Cursor session that ran before this child
882
+ * existed.
883
+ *
884
+ * The parent agent is optional here. A durable conversation row outlives the
885
+ * session that produced it — and one of them (`probe-parent`) never was a DSH
886
+ * session at all — so the lineage is rebuilt from what the durable record knows
887
+ * instead of from a live parent. Without a live parent there is no session to
888
+ * announce the catalog row into, and the dashboard's explicit
889
+ * `{parentSessionId, childSessionId, mode}` address is what opens the child.
890
+ *
891
+ * @param options - `agents`, `parent`, `parentSessionId`, `cwd`, `label`, `logger`.
892
+ * @returns `{child, handle, mirror, dispose}` or undefined.
893
+ */
894
+ export async function openRestoredChild(options) {
895
+ const { agents, parent, logger } = options;
896
+ if (agents === undefined || typeof agents.create !== 'function')
897
+ return undefined;
898
+ const live = parent?.session !== undefined && typeof parent.session.append === 'function';
899
+ // A restored child never runs a turn, so it can never delegate: it is a
900
+ // navigable RECORD of work Cursor already did, not a new delegation. The
901
+ // depth cap deliberately does not apply, because refusing here would only
902
+ // hide history the panel is trying to show.
903
+ const childDepth = live ? resolveChildDepth(parent, undefined) : 1;
904
+ const childId = `session-${randomUUID()}`;
905
+ const label = typeof options.label === 'string' && options.label.trim() !== '' ? options.label.trim() : undefined;
906
+ const handle = await agents.create({
907
+ sessionId: childId,
908
+ ...live ? { parentAgent: parent } : {},
909
+ meta: live
910
+ ? childSessionMeta(parent, childDepth, false)
911
+ : {
912
+ ...typeof options.cwd === 'string' && options.cwd !== '' ? { cwd: options.cwd } : {},
913
+ ...typeof options.parentSessionId === 'string' && options.parentSessionId !== ''
914
+ ? { parentSession: options.parentSessionId }
915
+ : {},
916
+ isSeeded: false,
917
+ origin: 'subagent',
918
+ delegationDepth: childDepth,
919
+ },
920
+ agentOptions: live
921
+ ? resolveChildAgentOptions(parent, undefined, childDepth)
922
+ // A restored child is a record, not a runner: it never executes a
923
+ // turn, so it needs no model route — only its own depth stamp.
924
+ : { subagentDepth: childDepth },
925
+ setup: (childCtx, child) => {
926
+ composeChild(childCtx, child, parent, { logger });
927
+ child.session.append('subagent/descriptor', options.descriptor ?? cursorDescriptor({ label }));
928
+ },
929
+ });
930
+ const child = handle.agent;
931
+ if (live)
932
+ announceChild(parent.session, child, label, logger);
933
+ return {
934
+ child,
935
+ handle,
936
+ mirror: new CursorMirror(child.session, options.model),
937
+ dispose: async () => {
938
+ await handle.dispose();
939
+ },
940
+ };
941
+ }
942
+
943
+ /**
944
+ * Apply the delegation policy and composition every child carries.
945
+ *
946
+ * `subagent/descriptor` is deliberately NOT part of this: it is written once, on
947
+ * creation, and a second copy would reset the client's timing origin.
948
+ *
949
+ * @param childCtx - the child's scoped context.
950
+ * @param child - the child agent.
951
+ * @param parent - the delegating agent.
952
+ * @param options - `logger`.
953
+ */
954
+ function composeChild(childCtx, child, parent, options = {}) {
955
+ try {
956
+ appendDelegatedPolicyOverrides(child.session, captureDelegatedPolicyOverrides(parent));
957
+ }
958
+ catch (error) {
959
+ options.logger?.warn?.(`dsh-cursor-dev: child policy was not recorded: ${error instanceof Error ? error.message : String(error)}`);
960
+ }
961
+ applyChildComposition(childCtx, parent, {}, options.logger);
962
+ }
963
+
964
+ /**
965
+ * Tell the parent's header that this child exists.
966
+ *
967
+ * The subagent runtime appends this itself when a provider goes through
968
+ * `ctx.subagents.start`; a run started straight from the tool has to append it
969
+ * here, because nothing else tells the parent that the child is navigable.
970
+ *
971
+ * @param parentSession - the parent's session.
972
+ * @param child - the child agent.
973
+ * @param label - the catalog row's label.
974
+ * @param logger - optional warning sink.
975
+ */
976
+ function announceChild(parentSession, child, label, logger) {
977
+ try {
978
+ parentSession.append('subagent/catalog', catalogPayload({
979
+ childId: child.session.header.id,
980
+ childCreatedAt: child.session.header.createdAt,
981
+ label,
982
+ }));
983
+ }
984
+ catch (error) {
985
+ // The child exists and works; only its entry in the parent's header is
986
+ // missing, so say so rather than failing the whole run.
987
+ logger?.warn?.(`dsh-cursor-dev: child catalog entry failed: ${error instanceof Error ? error.message : String(error)}`);
988
+ }
989
+ }
990
+
991
+ /**
992
+ * One child conversation per Cursor conversation.
993
+ *
994
+ * A Cursor conversation (`parent::label`) outlives a single turn, so its child
995
+ * conversation must too: the second turn of the same work appends a second turn
996
+ * to the SAME child log instead of opening a second, nearly empty row in the
997
+ * parent's header. Entries are keyed by the pool key, which is what the event
998
+ * stream is already keyed by.
999
+ *
1000
+ * A child is a live DSH agent, so the hub is bounded: the least recently used
1001
+ * conversation is closed once `MAX_CHILDREN` are open. Closing removes the agent
1002
+ * from the runtime, never the log — the durable history is what the client opens,
1003
+ * and a later run rejoins it through `resumeCursorChild`.
1004
+ *
1005
+ * The hub holds no timer and starts nothing: `open` is called by a run that is
1006
+ * about to happen, and `observe` is a no-op for any other conversation.
1007
+ *
1008
+ * @param options - `logger`, `maxChildren`, `maxChildDepth`.
1009
+ * @returns the hub used by the tool and by the subagent provider.
1010
+ */
1011
+ export function createMirrorHub(options = {}) {
1012
+ const logger = options.logger;
1013
+ const maxChildren = Number.isSafeInteger(options.maxChildren) && options.maxChildren > 0
1014
+ ? options.maxChildren
1015
+ : MAX_CHILDREN;
1016
+ // The recursion budget this hub enforces on every child it opens. `undefined`
1017
+ // keeps the previous behaviour (no cap); a number refuses a parent that is
1018
+ // already at that depth, which is how "only a top-level agent delegates"
1019
+ // (1) or "nobody delegates" (0) is configured.
1020
+ const maxChildDepth = options.maxChildDepth;
1021
+ const entries = new Map();
1022
+ let agents;
1023
+ let reportedMissing = false;
1024
+
1025
+ /** Close the least recently used conversations until the hub fits. */
1026
+ const reap = async () => {
1027
+ if (entries.size <= maxChildren)
1028
+ return;
1029
+ const doomed = [...entries.values()]
1030
+ .filter(entry => !(entry.references > 0) && !entry.mirror.open)
1031
+ .sort((left, right) => left.lastUsed - right.lastUsed)
1032
+ .slice(0, entries.size - maxChildren);
1033
+ for (const entry of doomed) {
1034
+ entries.delete(entry.key);
1035
+ entry.disposed = true;
1036
+ }
1037
+ await Promise.allSettled(doomed.map(entry => entry.dispose()));
1038
+ };
1039
+
1040
+ return {
1041
+ /**
1042
+ * Learn the `agents` service once its own fiber is ACTIVE.
1043
+ *
1044
+ * Reading it at mount time is what makes a service silently `undefined`
1045
+ * here (see the HTTP mount in `lib/index.js`), so the service is taken
1046
+ * from the injected context instead.
1047
+ *
1048
+ * @param ctx - a context that has declared the `agents` dependency.
1049
+ */
1050
+ bind(ctx) {
1051
+ const found = ctx?.get?.('agents');
1052
+ if (found !== undefined)
1053
+ agents = found;
1054
+ },
1055
+
1056
+ /** Whether a child conversation can be created at all. */
1057
+ get available() {
1058
+ return agents !== undefined;
1059
+ },
1060
+
1061
+ /**
1062
+ * What the status route shows: one row per open child conversation.
1063
+ *
1064
+ * @returns `{key, childSessionId, parentSessionId, turn, calls, open, resumed}` rows.
1065
+ */
1066
+ snapshot() {
1067
+ return [...entries.values()].map(entry => ({
1068
+ key: entry.key,
1069
+ childSessionId: entry.child?.session?.header?.id,
1070
+ parentSessionId: entry.child?.session?.header?.parentSession,
1071
+ turn: entry.mirror.turn,
1072
+ calls: entry.mirror.calls,
1073
+ open: entry.mirror.open,
1074
+ resumed: entry.resumed === true,
1075
+ restored: entry.restored === true,
1076
+ }));
1077
+ },
1078
+
1079
+ /**
1080
+ * Fold one Cursor event into whichever child conversation owns the key.
1081
+ *
1082
+ * @param key - pool key of the running conversation.
1083
+ * @param event - normalized event from `CursorSession`.
1084
+ */
1085
+ observe(key, event) {
1086
+ const entry = entries.get(key);
1087
+ if (entry === undefined)
1088
+ return;
1089
+ entry.lastUsed = Date.now();
1090
+ entry.mirror.observe(event);
1091
+ },
1092
+
1093
+ /**
1094
+ * Get or create the child conversation for one pool key.
1095
+ *
1096
+ * @param options - `key`, `parent`, `label`, `announce`, `childSessionId`.
1097
+ * @returns the entry, or undefined when no child conversation is possible.
1098
+ */
1099
+ async open(options) {
1100
+ const key = String(options.key);
1101
+ const existing = entries.get(key);
1102
+ if (existing !== undefined && existing.disposed !== true) {
1103
+ existing.lastUsed = Date.now();
1104
+ if (options.retain) existing.references = (existing.references ?? 0) + 1;
1105
+ return existing;
1106
+ }
1107
+ if (agents === undefined) {
1108
+ if (options.strict) throw new Error("cursor: agents service is unavailable");
1109
+ if (!reportedMissing) {
1110
+ reportedMissing = true;
1111
+ logger?.info?.('dsh-cursor-dev: child conversations are unavailable on this host (no `agents` service); runs still work');
1112
+ }
1113
+ return undefined;
1114
+ }
1115
+ // Rejoin the child a previous run opened for this conversation before
1116
+ // opening a new one: one Cursor conversation is one child log.
1117
+ const remembered = options.childSessionId;
1118
+ if (typeof remembered === 'string' && remembered !== '') {
1119
+ try {
1120
+ const resumed = await resumeCursorChild({
1121
+ agents,
1122
+ parent: options.parent,
1123
+ childSessionId: remembered,
1124
+ model: options.model,
1125
+ maxChildDepth,
1126
+ logger,
1127
+ });
1128
+ if (resumed !== undefined) {
1129
+ const entry = { ...resumed, key, disposed: false, resumed: true, references: options.retain ? 1 : 0, lastUsed: Date.now() };
1130
+ entries.set(key, entry);
1131
+ await reap();
1132
+ return entry;
1133
+ }
1134
+ }
1135
+ catch (error) {
1136
+ // The remembered child is gone (pruned log, another host, a
1137
+ // cleared store): open a fresh one rather than losing the view.
1138
+ logger?.warn?.(`dsh-cursor-dev: could not reopen child ${remembered}: ${error instanceof Error ? error.message : String(error)}`);
1139
+ }
1140
+ }
1141
+ try {
1142
+ const opened = await openCursorChild({
1143
+ agents,
1144
+ parent: options.parent,
1145
+ label: options.label,
1146
+ model: options.model,
1147
+ descriptor: options.descriptor,
1148
+ announce: options.announce,
1149
+ maxChildDepth,
1150
+ logger,
1151
+ });
1152
+ if (opened === undefined) {
1153
+ if (options.strict) throw new Error("cursor: a native child could not be created");
1154
+ return undefined;
1155
+ }
1156
+ const entry = { ...opened, key, disposed: false, resumed: false, references: options.retain ? 1 : 0, lastUsed: Date.now() };
1157
+ entries.set(key, entry);
1158
+ await reap();
1159
+ return entry;
1160
+ }
1161
+ catch (error) {
1162
+ if (options.strict) throw error;
1163
+ // Standalone hosts can still run without a child transcript.
1164
+ logger?.warn?.(`dsh-cursor-dev: could not open a child conversation: ${error instanceof Error ? error.message : String(error)}`);
1165
+ return undefined;
1166
+ }
1167
+ },
1168
+
1169
+ /**
1170
+ * Publish a replayed Cursor transcript as a child conversation.
1171
+ *
1172
+ * The child is created here rather than in `open()` because the caller
1173
+ * already has the transcript: a restore reads the CLI's store first and
1174
+ * only then has something to publish. The live parent is looked up by
1175
+ * id and may be gone — a durable row outlives its session, and one of
1176
+ * them was never a DSH session at all.
1177
+ *
1178
+ * @param options - `key`, `parentSessionId`, `cwd`, `label`, `events`, `model`.
1179
+ * @returns the hub entry, or undefined when no child could be published.
1180
+ */
1181
+ async restore(options) {
1182
+ if (agents === undefined)
1183
+ return undefined;
1184
+ const key = String(options.key);
1185
+ const existing = entries.get(key);
1186
+ if (existing !== undefined && existing.disposed !== true) {
1187
+ existing.lastUsed = Date.now();
1188
+ return existing;
1189
+ }
1190
+ const parent = typeof agents.get === 'function' ? agents.get(options.parentSessionId) : undefined;
1191
+ let opened;
1192
+ try {
1193
+ opened = await openRestoredChild({
1194
+ agents,
1195
+ parent,
1196
+ parentSessionId: options.parentSessionId,
1197
+ cwd: options.cwd,
1198
+ label: options.label,
1199
+ logger,
1200
+ });
1201
+ }
1202
+ catch (error) {
1203
+ logger?.warn?.(`dsh-cursor-dev: could not publish the restored conversation: ${error instanceof Error ? error.message : String(error)}`);
1204
+ return undefined;
1205
+ }
1206
+ if (opened === undefined)
1207
+ return undefined;
1208
+ try {
1209
+ mirrorTranscript(opened.mirror, options.events ?? [], { model: options.model });
1210
+ }
1211
+ catch (error) {
1212
+ // The child exists but its log is incomplete: hand back nothing
1213
+ // so the caller does not record an id that reads as a broken
1214
+ // conversation, and let disposal clean the half-written log up.
1215
+ logger?.warn?.(`dsh-cursor-dev: could not write the restored transcript: ${error instanceof Error ? error.message : String(error)}`);
1216
+ try {
1217
+ await opened.dispose();
1218
+ }
1219
+ catch {
1220
+ // Best effort; the persisted log is the record.
1221
+ }
1222
+ return undefined;
1223
+ }
1224
+ const entry = { ...opened, key, disposed: false, resumed: false, restored: true, lastUsed: Date.now() };
1225
+ entries.set(key, entry);
1226
+ await reap();
1227
+ return entry;
1228
+ },
1229
+
1230
+ /**
1231
+ * Dispose every child conversation belonging to one conversation line.
1232
+ *
1233
+ * `resetSession` starts a new generation of the same work, so the old
1234
+ * child log must be closed rather than left open behind a key nothing
1235
+ * will ever ask for again.
1236
+ *
1237
+ * @param conversationKey - `parent::label`.
1238
+ */
1239
+ async dropConversation(conversationKey) {
1240
+ const prefix = `${conversationKey}#`;
1241
+ const doomed = [...entries.entries()].filter(([key]) => key.startsWith(prefix));
1242
+ for (const [key] of doomed)
1243
+ entries.delete(key);
1244
+ await Promise.allSettled(doomed.map(([, entry]) => entry.dispose()));
1245
+ },
1246
+
1247
+ /**
1248
+ * Close the mirrored turn for one key, keeping the conversation open.
1249
+ *
1250
+ * @param key - pool key.
1251
+ * @param outcome - `{text, reasoning, ok, stopReason, error, model, cancelled}`.
1252
+ */
1253
+ close(key, outcome) {
1254
+ const entry = entries.get(String(key));
1255
+ if (entry === undefined)
1256
+ return;
1257
+ try {
1258
+ entry.mirror.finish(outcome);
1259
+ }
1260
+ catch (error) {
1261
+ logger?.warn?.(`dsh-cursor-dev: could not close the mirrored turn: ${error instanceof Error ? error.message : String(error)}`);
1262
+ }
1263
+ },
1264
+
1265
+ /**
1266
+ * Dispose one child agent and forget its conversation.
1267
+ *
1268
+ * @param key - pool key.
1269
+ */
1270
+ async release(key, options = {}) {
1271
+ const entry = entries.get(String(key));
1272
+ if (entry === undefined || (options.entry !== undefined && options.entry !== entry))
1273
+ return;
1274
+ if (entry.references > 0) entry.references -= 1;
1275
+ if (entry.references > 0) return;
1276
+ entries.delete(String(key));
1277
+ entry.disposed = true;
1278
+ try {
1279
+ await entry.dispose();
1280
+ }
1281
+ catch (error) {
1282
+ if (options.strict) throw error;
1283
+ }
1284
+ },
1285
+
1286
+ /** Dispose every child agent (plugin unload). */
1287
+ async dispose() {
1288
+ const all = [...entries.values()];
1289
+ entries.clear();
1290
+ await Promise.allSettled(all.map(entry => entry.dispose()));
1291
+ },
1292
+ };
1293
+ }