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/tool.js ADDED
@@ -0,0 +1,747 @@
1
+ /**
2
+ * The `cursor_code` tool: hand a coding job to the Cursor CLI as a child agent.
3
+ *
4
+ * Design intent (the five gaps this plugin exists to close):
5
+ *
6
+ * 1. VISIBILITY — every `session/update` is journaled: reasoning chunks, each
7
+ * tool call with its outcome, and the real `{oldText,newText}` diffs Cursor
8
+ * applies. The result carries the change list and step timeline, and the UI
9
+ * renders a diff card, so the parent can verify instead of trusting a summary.
10
+ * 2. PER-CALL MODEL — `model` selects the Cursor model for this job only, so a
11
+ * cheap model can do boilerplate while a strong one does architecture.
12
+ * 3. CONTINUITY — `continueSession` keeps the same Cursor conversation; the pool
13
+ * holds the live process so a follow-up is a real continuation.
14
+ * 4. CONTEXT — `contextFiles` + `acceptance` let the parent point the child at the
15
+ * right code without hand-writing the entire prompt.
16
+ * 5. VERIFIABLE EVIDENCE — a filesystem fingerprint before/after the turn catches
17
+ * changes the ACP stream never mentioned (shell writes, moves, deletes).
18
+ */
19
+ import { join } from 'node:path';
20
+ import { fingerprint, compareFingerprints, mergeChanges } from './changes.js';
21
+ import { delegationDepthOf, resolveChildDepth } from './mirror.js';
22
+ import { sameModel } from './models.js';
23
+ import { buildPrompt } from './prompt.js';
24
+ import { storeKeyOf } from './session-pool.js';
25
+
26
+ /** Default wall-clock budget for one Cursor turn. */
27
+ const DEFAULT_TIMEOUT_SECONDS = 1800;
28
+ /** Hard ceiling, regardless of what the model asks for. */
29
+ const MAX_TIMEOUT_SECONDS = 7200;
30
+
31
+ /**
32
+ * The host refuses a tool result that a JSON round-trip would not reproduce
33
+ * ("value is not lossless JSON"): an `undefined` value disappears, so a result
34
+ * carrying one — an absent `reasoning`, a diff without line counts, a session
35
+ * whose model the CLI never reported — is rejected and the whole turn's work is
36
+ * lost to the caller. Strip those values instead of returning them.
37
+ *
38
+ * Arrays keep their order and drop empty slots; objects drop the whole key;
39
+ * non-finite numbers become `null` (visible) rather than vanishing (silent).
40
+ */
41
+ export function toLosslessJson(value) {
42
+ return clean(value, new WeakSet());
43
+ }
44
+
45
+ /** `seen` holds the current ancestors, so a cycle is dropped without hanging. */
46
+ function clean(value, seen) {
47
+ if (Array.isArray(value)) {
48
+ if (seen.has(value))
49
+ return undefined;
50
+ seen.add(value);
51
+ const items = [];
52
+ for (const item of value) {
53
+ const inner = clean(item, seen);
54
+ if (inner !== undefined)
55
+ items.push(inner);
56
+ }
57
+ seen.delete(value);
58
+ return items;
59
+ }
60
+ if (value !== null && typeof value === 'object') {
61
+ if (value instanceof Date)
62
+ return value.toISOString();
63
+ if (value instanceof Map)
64
+ return clean(Object.fromEntries(value), seen);
65
+ if (value instanceof Set)
66
+ return clean([...value], seen);
67
+ if (seen.has(value))
68
+ return undefined;
69
+ seen.add(value);
70
+ const out = {};
71
+ for (const [key, item] of Object.entries(value)) {
72
+ const inner = clean(item, seen);
73
+ if (inner !== undefined)
74
+ out[key] = inner;
75
+ }
76
+ seen.delete(value);
77
+ return out;
78
+ }
79
+ if (typeof value === 'number')
80
+ return Number.isFinite(value) ? value : null;
81
+ if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint')
82
+ return undefined;
83
+ return value;
84
+ }
85
+
86
+ /**
87
+ * The tool definition, registered through `ctx.tools.register`.
88
+ *
89
+ * @param deps - plugin services and shared state.
90
+ */
91
+ export function cursorCodeTool(deps) {
92
+ const { pool, journal, index, logger, unavailable, mirrors } = deps;
93
+ // The recursion budget this tool enforces: the child it starts is a real
94
+ // DSH child session, so a subagent calling this tool would create a
95
+ // GRANDCHILD. `1` is the platform's own default (only a top-level agent
96
+ // delegates) and `0` forbids Cursor children entirely.
97
+ const maxChildDepth = deps.maxChildDepth;
98
+
99
+ return {
100
+ name: 'cursor_code',
101
+ description:
102
+ 'Delegate a coding job in this workspace to the Cursor CLI child agent, which edits files on disk using its own tools. '
103
+ + 'Use it for real implementation work: writing or refactoring code, fixing bugs, adding tests, mechanical multi-file changes. '
104
+ + 'Do NOT use it for questions you can answer yourself, or for reading files — use your own tools for that.\n'
105
+ + 'The child does not see this conversation: put everything it needs in `task`, and list files it should read in `contextFiles`.\n'
106
+ + 'It reports back its final message, every file it changed (with diffs), and a step timeline of the tools it ran.\n'
107
+ + 'To iterate on the same work, call again with continueSession:true — the child keeps its conversation and remembers what it did, '
108
+ + 'even after DSH restarts. Runs are serialized (one Cursor turn at a time). Pick a cheaper or stronger model per call with `model`.',
109
+ parameters: {
110
+ type: 'object',
111
+ properties: {
112
+ task: {
113
+ type: 'string',
114
+ description:
115
+ 'The coding job, in full. State the goal, the concrete changes wanted, the relevant file paths, and what "done" looks like. '
116
+ + 'The child sees this text and nothing else from this conversation.',
117
+ },
118
+ contextFiles: {
119
+ type: 'array',
120
+ items: { type: 'string' },
121
+ description:
122
+ 'Workspace-relative (or absolute) paths the child should read before starting. A cheap way to give it the right context.',
123
+ },
124
+ acceptance: {
125
+ type: 'string',
126
+ description: 'Optional acceptance criteria or constraints the child must satisfy (e.g. "must keep the public API", "run pytest before finishing").',
127
+ },
128
+ model: {
129
+ type: 'string',
130
+ description:
131
+ 'Cursor model for this job. Examples: composer-2.5 (fast, cheap edits), grok-4.7 (strong reasoning), kimi-k3, glm-5.2. '
132
+ + 'Suffixes like [reasoning_effort=high,fast=true] are accepted. Omit to use the model the operator picked in the '
133
+ + 'Cursor panel (which falls back to the CLI default). If the operator locks the panel model, that selection overrides this argument. '
134
+ + 'The Cursor CLI applies the model globally, so runs are serialized; changing it starts a new Cursor session '
135
+ + 'that still carries the conversation transcript (the child keeps its memory of what it did).',
136
+ },
137
+ mode: {
138
+ type: 'string',
139
+ enum: ['agent', 'plan', 'ask'],
140
+ description: 'Cursor mode: agent (edits files, default), plan (read-only planning), ask (read-only Q&A).',
141
+ },
142
+ continueSession: {
143
+ type: 'boolean',
144
+ description:
145
+ 'Continue the Cursor conversation this tool already started for this session, so the child remembers prior turns. '
146
+ + 'Default false. Use true for follow-up fixes to the same work. Continuity survives DSH restarts: the conversation is '
147
+ + 'reloaded from Cursor\'s own store when its process is gone.',
148
+ },
149
+ sessionLabel: {
150
+ type: 'string',
151
+ description:
152
+ 'Optional name for this conversation, to keep several independent Cursor conversations apart within one session. '
153
+ + 'Reuse the same label to continue the same work line; combine with continueSession. Defaults to "default".',
154
+ },
155
+ resetSession: {
156
+ type: 'boolean',
157
+ description:
158
+ 'Start over for this session label: this plugin forgets the Cursor session id, so the next run cannot reload the old '
159
+ + 'conversation. Nothing is deleted inside Cursor\'s own store. Default false.',
160
+ },
161
+ workspaceRoot: {
162
+ type: 'string',
163
+ description: 'Absolute directory the child works in. Omit to use this session\'s working directory.',
164
+ },
165
+ report: {
166
+ type: 'string',
167
+ enum: ['summary', 'detailed'],
168
+ description:
169
+ 'summary (default) returns the final message, changed files and a step list; detailed additionally includes the reasoning summary and per-step detail.',
170
+ },
171
+ timeoutSeconds: {
172
+ type: 'number',
173
+ description: `Wall-clock budget for this turn. Default ${DEFAULT_TIMEOUT_SECONDS}, capped at ${MAX_TIMEOUT_SECONDS}.`,
174
+ },
175
+ },
176
+ required: ['task'],
177
+ additionalProperties: false,
178
+ },
179
+ output: {
180
+ schema: {
181
+ type: 'object',
182
+ properties: {
183
+ ok: { type: 'boolean' },
184
+ sessionKey: { type: 'string' },
185
+ cursorSessionId: { type: 'string' },
186
+ childSessionId: {
187
+ type: 'string',
188
+ description: 'Child DSH conversation holding the mirrored transcript, when the host provides one.',
189
+ },
190
+ reused: { type: 'boolean' },
191
+ reloaded: { type: 'boolean' },
192
+ model: { type: 'string' },
193
+ modelSource: { type: 'string' },
194
+ requestedModel: { type: 'string' },
195
+ mode: { type: 'string' },
196
+ stopReason: { type: 'string' },
197
+ durationMs: { type: 'number' },
198
+ text: { type: 'string' },
199
+ steps: { type: 'array', items: { type: 'object' } },
200
+ changes: { type: 'array', items: { type: 'object' } },
201
+ preview: {
202
+ type: 'array',
203
+ items: {
204
+ type: 'object',
205
+ properties: {
206
+ path: { type: 'string' },
207
+ // The host accepts a JSON Schema subset that has no
208
+ // type arrays, so "string or null" is a oneOf.
209
+ oldText: {
210
+ oneOf: [{ type: 'string' }, { type: 'null' }],
211
+ description: 'Previous file text, or null for a new file.',
212
+ },
213
+ newText: { type: 'string' },
214
+ },
215
+ required: ['path', 'newText'],
216
+ },
217
+ },
218
+ reasoning: { type: 'string' },
219
+ scannedBefore: { type: 'number' },
220
+ error: { type: 'string' },
221
+ },
222
+ required: ['ok', 'sessionKey'],
223
+ },
224
+ render: (_args, value) => renderCursorReport(value),
225
+ },
226
+
227
+ /**
228
+ * Run one Cursor turn.
229
+ *
230
+ * @param args - parsed tool arguments.
231
+ * @param exec - tool run context (agent, signal).
232
+ */
233
+ async execute(args, exec) {
234
+ // The plugin mounts even when no CLI was found; say so plainly
235
+ // instead of failing the fiber and hiding every other surface.
236
+ if (unavailable !== undefined) {
237
+ return {
238
+ ok: false,
239
+ sessionKey: 'unavailable',
240
+ stopReason: 'error',
241
+ durationMs: 0,
242
+ error: unavailable,
243
+ steps: [],
244
+ changes: [],
245
+ };
246
+ }
247
+ const raw = args !== null && typeof args === 'object' ? args : {};
248
+ const workspaceRootRaw = typeof raw.workspaceRoot === 'string' && raw.workspaceRoot.trim() !== ''
249
+ ? raw.workspaceRoot.trim()
250
+ : resolveCwd(exec) ?? process.cwd();
251
+
252
+ let input;
253
+ try {
254
+ input = normalizeArgs(args, workspaceRootRaw);
255
+ }
256
+ catch (error) {
257
+ return {
258
+ ok: false,
259
+ sessionKey: 'invalid',
260
+ stopReason: 'error',
261
+ durationMs: 0,
262
+ error: error instanceof Error ? error.message : String(error),
263
+ steps: [],
264
+ changes: [],
265
+ };
266
+ }
267
+
268
+ const parentId = String(exec.agent?.id ?? 'unknown');
269
+ const workspaceRoot = input.workspaceRoot ?? workspaceRootRaw;
270
+ const conversationKey = `${parentId}::${input.label}`;
271
+ const storeKey = storeKeyOf(conversationKey);
272
+
273
+ // The depth cap is checked BEFORE anything is created or spawned, so
274
+ // a refused call costs nothing: no child session, no CLI process, no
275
+ // Cursor conversation left half-started.
276
+ if (typeof maxChildDepth === 'number') {
277
+ try {
278
+ resolveChildDepth(exec.agent, maxChildDepth);
279
+ }
280
+ catch (error) {
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ logger?.warn?.(`cursor_code refused: ${message}`);
283
+ return toLosslessJson({
284
+ ok: false, sessionKey: 'depth-refused', conversationKey,
285
+ stopReason: 'error', durationMs: 0,
286
+ error: `${message}. The calling agent is at delegation depth `
287
+ + `${delegationDepthOf(exec.agent)} and this host caps Cursor children at ${maxChildDepth}. `
288
+ + 'Raise `maxChildDepth` in the plugin config to let a subagent start a Cursor run.',
289
+ steps: [], changes: [],
290
+ });
291
+ }
292
+ }
293
+
294
+ if (input.resetSession) {
295
+ pool.dropConversation(conversationKey);
296
+ journal.clear(conversationKey);
297
+ await index.delete(conversationKey);
298
+ // The reset is a NEW generation of this work: the old child log
299
+ // is closed here so it cannot be reopened as this run's view.
300
+ await mirrors?.dropConversation(conversationKey);
301
+ }
302
+
303
+ // A conversation's durable transcript survives the death of its
304
+ // process, so any generation can reload it. The record is consulted
305
+ // here (rather than inside the pool) because the model chosen for
306
+ // this turn decides whether the continuation is even valid.
307
+ // A call may name a model, otherwise the operator's choice from the
308
+ // panel applies, otherwise the profile config, otherwise the CLI
309
+ // default. Resolved once per call so the whole turn uses one model.
310
+ const selection = deps.resolveModelSelection?.(input.model) ?? {
311
+ model: input.model ?? nonEmpty(deps.resolveDefaultModel?.()),
312
+ source: input.model !== undefined ? 'request' : 'default',
313
+ requestedModel: input.model,
314
+ };
315
+ const pickedModel = selection.model;
316
+ const pickedMode = input.mode ?? nonEmpty(deps.resolveDefaultMode?.());
317
+ const durable = await index.get(conversationKey);
318
+ const remembered = pool.persistedFor(storeKey) ?? durable;
319
+ const rememberedSessionId = remembered?.cursorSessionId ?? remembered?.sessionId;
320
+ // The child conversation mirrors the Cursor conversation, so it is
321
+ // remembered by the same durable record and reopened instead of
322
+ // duplicated when a later run resumes this work.
323
+ const rememberedChildId = durable?.childSessionId ?? remembered?.childSessionId;
324
+ // The model in effect is asked of the POOL, never read off the
325
+ // durable record: the record holds the id the CLI reported (a
326
+ // roster spelling like `composer-2.5[fast=true]`) while a caller
327
+ // writes an alias (`composer-2.5-fast`, `auto`), so a raw `!==`
328
+ // fired on the very same model and silently started a new
329
+ // generation — losing the live conversation on every follow-up.
330
+ // `sameModel` compares through both name spaces, so only a genuine
331
+ // switch counts: `auto` → `default[]` and an alias → its variant are
332
+ // the same model. `pickedModel` still reaches `pool.run` unchanged,
333
+ // because the pool owns the resolution against the live roster.
334
+ const currentModel = typeof pool.currentModelChoice === 'function'
335
+ ? pool.currentModelChoice(storeKey)
336
+ : remembered?.modelChoice ?? remembered?.model;
337
+ // The pool resolves the model IN EFFECT through the roster it has
338
+ // already scoped to THIS conversation. This is the "what would a
339
+ // switch be compared against" answer, and it is deliberately the
340
+ // CURRENT model, not the incoming one: resolving the request and
341
+ // then comparing it with itself collapses to "not changed" and
342
+ // reuses the live generation across a genuine model switch. The
343
+ // request is resolved in the SAME call so both sides share one
344
+ // roster — the alias the CLI publishes (`composer-2.5-fast`) and
345
+ // the id that alias stands for (`composer-2.5[fast=true]`) are one
346
+ // model, and without that third term the gate abandoned the live
347
+ // generation on a turn the pool itself switched nothing for.
348
+ const current = typeof pool.modelContext === 'function' ? pool.modelContext(storeKey, pickedModel) : {};
349
+ const currentModelId = current.id
350
+ ?? (currentModel === undefined || currentModel === '' ? undefined : currentModel);
351
+ // Only a roster LISTING can bridge two spellings: when no roster
352
+ // answered, `resolveModel` hands the requested name straight back
353
+ // (`listed: false`), and treating that as an id would compare a
354
+ // request with itself and call a genuine switch a continuation.
355
+ const requestModelId = current.request?.listed === true ? current.request.id : undefined;
356
+ const modelChanged = pickedModel !== undefined
357
+ && !sameModel(currentModel, pickedModel)
358
+ && !sameModel(currentModelId, pickedModel)
359
+ && !sameModel(currentModelId, requestModelId);
360
+
361
+ // `continueSession:true` reuses the live generation; anything else
362
+ // starts a new one, so a fresh run can never inherit live context.
363
+ const wantsContinue = input.continueSession && !modelChanged;
364
+ const picked = pool.pickKey(conversationKey, wantsContinue ? 'continue' : 'fresh');
365
+ const sessionKey = picked.key;
366
+
367
+ const timeoutSeconds = Math.min(input.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS);
368
+ const abort = new AbortController();
369
+ const onOuterAbort = () => abort.abort(exec.signal?.reason);
370
+ exec.signal?.addEventListener('abort', onOuterAbort, { once: true });
371
+ if (exec.signal?.aborted) onOuterAbort();
372
+ const timer = setTimeout(() => abort.abort(), timeoutSeconds * 1000);
373
+ const startedAt = Date.now();
374
+ let session;
375
+ let child;
376
+ let childId;
377
+ let reused = false;
378
+ let reloaded = false;
379
+ const lifecycle = exec.cursorLifecycle;
380
+ // External native runs live in the subagents service as a row without a
381
+ // local transcript; only a lifecycle that asks for the mirror creates one.
382
+ const mirrorEnabled = lifecycle?.mirror !== false;
383
+ const openChild = async () => {
384
+ const entry = await mirrors?.open({
385
+ key: sessionKey,
386
+ parent: exec.agent,
387
+ label: lifecycle?.label ?? input.label,
388
+ childSessionId: rememberedChildId,
389
+ model: session?.models?.currentModelId ?? pickedModel,
390
+ ...(lifecycle === undefined ? {} : {
391
+ descriptor: lifecycle.descriptor, announce: false, strict: true, retain: true,
392
+ }),
393
+ });
394
+ childId = entry?.child?.session?.header?.id;
395
+ return entry;
396
+ };
397
+ try {
398
+ abort.signal.throwIfAborted();
399
+ // Native jobs publish the actual child before joining the CLI's
400
+ // FIFO. The host can now cancel a queued job as well as a prompt.
401
+ if (mirrorEnabled && lifecycle !== undefined) {
402
+ child = await openChild();
403
+ if (child === undefined) throw new Error('cursor: native child agent is unavailable');
404
+ lifecycle.opened(child, sessionKey);
405
+ }
406
+ return await pool.run({
407
+ key: sessionKey,
408
+ storeKey,
409
+ cwd: workspaceRoot,
410
+ model: pickedModel,
411
+ persistedSessionId: rememberedSessionId,
412
+ signal: abort.signal,
413
+ }, async (lease) => {
414
+ session = lease.session;
415
+ lifecycle?.acquired?.(session);
416
+ reused = lease.reused;
417
+ reloaded = lease.reloaded === true;
418
+ // A reused process was spawned with a different turn's
419
+ // signal. Cancel this lease explicitly and wait for exit
420
+ // before releasing the global CLI model lock.
421
+ const cancelLease = () => session.dispose();
422
+ abort.signal.addEventListener('abort', cancelLease, { once: true });
423
+ let turn;
424
+ try {
425
+ abort.signal.throwIfAborted();
426
+ journal.annotate(sessionKey, {
427
+ task: input.task, cursorSessionId: session.sessionId,
428
+ workspaceRoot, model: session.models?.currentModelId,
429
+ modelSource: selection.source, requestedModel: selection.requestedModel, reloaded,
430
+ });
431
+ if (mirrorEnabled) child ??= await openChild();
432
+ child?.mirror.useModel?.(session.models?.currentModelId);
433
+ child?.mirror.begin(input.prompt);
434
+ const before = await fingerprint(workspaceRoot, deps.maxScanFiles);
435
+ abort.signal.throwIfAborted();
436
+ if (pickedMode !== undefined && session.modes?.currentModeId !== pickedMode)
437
+ await session.setMode(pickedMode);
438
+ turn = await session.prompt(input.prompt, abort.signal);
439
+ pool.recordTurn(sessionKey, turn);
440
+ const after = await fingerprint(workspaceRoot, deps.maxScanFiles);
441
+ const changes = mergeChanges(turn.diffs, compareFingerprints(before, after), workspaceRoot);
442
+ // Persist and close the mirror while this turn still owns
443
+ // the FIFO, so a continued turn cannot overtake its log.
444
+ await index.set(conversationKey, {
445
+ cursorSessionId: session.sessionId, sessionKey, workspaceRoot,
446
+ model: session.models?.currentModelId, updatedAt: Date.now(),
447
+ turns: (durable?.turns ?? 0) + 1,
448
+ ...childId === undefined ? {} : { childSessionId: childId },
449
+ });
450
+ return toLosslessJson({
451
+ ok: turn.stopReason === 'end_turn' && !abort.signal.aborted,
452
+ sessionKey, conversationKey, generation: picked.generation,
453
+ cursorSessionId: session.sessionId, childSessionId: childId,
454
+ reused, reloaded, model: session.models?.currentModelId,
455
+ modelSource: selection.source, requestedModel: selection.requestedModel,
456
+ mode: session.modes?.currentModeId,
457
+ stopReason: abort.signal.aborted ? 'cancelled' : turn.stopReason,
458
+ durationMs: turn.durationMs, text: turn.text,
459
+ steps: journal.timeline(sessionKey, input.report === 'detailed' ? 80 : 40),
460
+ changes: changes.map(change => ({ path: change.path, operation: change.operation,
461
+ added: change.added, removed: change.removed, source: change.source })),
462
+ preview: previewDiffs(changes),
463
+ reasoning: input.report === 'detailed' ? turn.reasoning : undefined,
464
+ scannedBefore: before.files.size,
465
+ truncatedScan: before.truncated || after.truncated || undefined,
466
+ usage: turn.usage,
467
+ });
468
+ }
469
+ catch (error) {
470
+ turn = error?.partialTurn ?? turn;
471
+ throw error;
472
+ }
473
+ finally {
474
+ abort.signal.removeEventListener('abort', cancelLease);
475
+ mirrors?.close(sessionKey, {
476
+ text: turn?.text, reasoning: turn?.reasoning,
477
+ ok: turn?.stopReason === 'end_turn' && !abort.signal.aborted,
478
+ stopReason: abort.signal.aborted ? 'cancelled' : turn?.stopReason ?? 'error',
479
+ model: session.models?.currentModelId,
480
+ });
481
+ if (abort.signal.aborted)
482
+ await session.disposeAndWait?.();
483
+ }
484
+ });
485
+ }
486
+ catch (error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ logger?.warn?.(`cursor_code failed: ${message}`);
489
+ return toLosslessJson({
490
+ ok: false, sessionKey, conversationKey, generation: picked.generation,
491
+ cursorSessionId: session?.sessionId, childSessionId: childId,
492
+ reused, reloaded, model: session?.models?.currentModelId,
493
+ modelSource: selection.source, requestedModel: selection.requestedModel,
494
+ stopReason: abort.signal.aborted ? 'cancelled' : 'error',
495
+ durationMs: Date.now() - startedAt, error: message,
496
+ text: error?.partialTurn?.text,
497
+ steps: journal.timeline(sessionKey, 40), changes: [],
498
+ });
499
+ }
500
+ finally {
501
+ clearTimeout(timer);
502
+ exec.signal?.removeEventListener('abort', onOuterAbort);
503
+ }
504
+ },
505
+
506
+ /** Deny concurrent runs of this tool; each turn owns a process. */
507
+ isConcurrencySafe() {
508
+ return false;
509
+ },
510
+
511
+ /** Show the user what was asked, before it runs. */
512
+ presentCall(args) {
513
+ const raw = args !== null && typeof args === 'object' ? args : {};
514
+ return {
515
+ card: 'generic',
516
+ title: `Cursor → ${summarize(raw.task)}`,
517
+ kind: 'execute',
518
+ rawInput: {
519
+ model: typeof raw.model === 'string' ? raw.model : 'default',
520
+ mode: typeof raw.mode === 'string' ? raw.mode : 'agent',
521
+ continue: raw.continueSession === true,
522
+ cwd: typeof raw.workspaceRoot === 'string' ? raw.workspaceRoot : '(session cwd)',
523
+ },
524
+ };
525
+ },
526
+
527
+ /** Render the real diffs in the UI, independently of what the model sees. */
528
+ presentResult(_args, result) {
529
+ if (result?.isError === true)
530
+ return undefined;
531
+ const value = result?.value;
532
+ if (value === undefined || value === null || typeof value !== 'object')
533
+ return undefined;
534
+ const preview = Array.isArray(value.preview) ? value.preview : [];
535
+ if (preview.length === 0)
536
+ return undefined;
537
+ const changed = Array.isArray(value.changes) ? value.changes : [];
538
+ return {
539
+ card: 'diff',
540
+ title: `${preview.length} file${preview.length === 1 ? '' : 's'} changed by Cursor`,
541
+ diffs: preview.map(entry => ({
542
+ path: entry.path,
543
+ oldText: entry.oldText,
544
+ newText: entry.newText,
545
+ })),
546
+ meta: {
547
+ model: value.model,
548
+ durationMs: value.durationMs,
549
+ continued: value.reused === true,
550
+ reloaded: value.reloaded === true,
551
+ operations: Object.fromEntries(changed.map(change => [change.path, change.operation])),
552
+ },
553
+ };
554
+ },
555
+ };
556
+ }
557
+
558
+ /** Longest diff side handed to the UI card, per file. */
559
+ const MAX_PREVIEW_CHARS = 20000;
560
+
561
+ /**
562
+ * Build the diff payload for the UI card.
563
+ *
564
+ * The ACP stream carries real `{oldText,newText}` pairs for edits Cursor applied
565
+ * itself; a change only seen by the filesystem fingerprint has no text, and is
566
+ * reported as a whole-file addition so the card still shows something true.
567
+ *
568
+ * @param changes - the merged change list from {@link mergeChanges}.
569
+ */
570
+ function previewDiffs(changes) {
571
+ const preview = [];
572
+ for (const change of changes) {
573
+ if (change.operation === 'deleted' && change.diff === undefined) {
574
+ preview.push({ path: change.path, oldText: '(deleted; content not captured)', newText: '' });
575
+ continue;
576
+ }
577
+ if (change.diff !== undefined && typeof change.diff.newText === 'string') {
578
+ preview.push({
579
+ path: change.path,
580
+ oldText: clip(change.diff.oldText),
581
+ newText: clip(change.diff.newText),
582
+ });
583
+ continue;
584
+ }
585
+ // Fingerprint-only evidence: no text was observed, so say so rather
586
+ // than fabricating a diff the user would misread as the real edit.
587
+ preview.push({
588
+ path: change.path,
589
+ oldText: change.operation === 'created' ? null : '(changed outside the ACP diff stream)',
590
+ newText: `(${change.operation}; +${change.added ?? 0}/-${change.removed ?? 0} lines — full text not reported by Cursor)`,
591
+ });
592
+ }
593
+ return preview;
594
+ }
595
+
596
+ /**
597
+ * Bound one diff side so a huge write cannot bloat the tool result.
598
+ * @param text - diff text or null.
599
+ */
600
+ function clip(text) {
601
+ if (typeof text !== 'string')
602
+ return text ?? null;
603
+ if (text.length <= MAX_PREVIEW_CHARS)
604
+ return text;
605
+ return `${text.slice(0, MAX_PREVIEW_CHARS)}\n… (${text.length - MAX_PREVIEW_CHARS} more characters omitted)`;
606
+ }
607
+
608
+ /**
609
+ * Render the compact model-facing text for a tool result.
610
+ *
611
+ * @param value - the tool's return value.
612
+ */
613
+ export function renderCursorReport(value) {
614
+ const lines = [];
615
+ if (value.ok === false) {
616
+ lines.push(`cursor_code FAILED: ${value.error ?? 'unknown error'}`);
617
+ }
618
+ else {
619
+ lines.push(`cursor_code finished (${value.stopReason}, ${Math.round((value.durationMs ?? 0) / 1000)}s, model=${value.model ?? '?'}${value.reused ? ', continued session' : ''}).`);
620
+ }
621
+ lines.push(`sessionKey: ${value.sessionKey}`);
622
+ if (value.childSessionId !== undefined && value.childSessionId !== '')
623
+ lines.push(`childSessionId: ${value.childSessionId}`);
624
+ if (value.text !== undefined && value.text !== '')
625
+ lines.push('', '--- child report ---', value.text);
626
+ if (Array.isArray(value.changes) && value.changes.length > 0) {
627
+ lines.push('', `--- files changed (${value.changes.length}) ---`);
628
+ for (const change of value.changes) {
629
+ const delta = change.added !== undefined || change.removed !== undefined
630
+ ? ` (+${change.added ?? 0}/-${change.removed ?? 0})`
631
+ : '';
632
+ lines.push(`${change.operation} ${change.path}${delta}`);
633
+ }
634
+ }
635
+ else {
636
+ lines.push('', '--- files changed: NONE ---');
637
+ }
638
+ if (Array.isArray(value.steps) && value.steps.length > 0) {
639
+ lines.push('', `--- steps (${value.steps.length}) ---`);
640
+ for (const step of value.steps) {
641
+ const target = step.path !== undefined ? ` ${step.path}` : '';
642
+ lines.push(`${step.status ?? '?'} ${step.kind ?? 'tool'}: ${step.name ?? ''}${target}`);
643
+ }
644
+ }
645
+ if (value.reasoning !== undefined && value.reasoning !== '')
646
+ lines.push('', '--- reasoning ---', value.reasoning);
647
+ if (value.truncatedScan === true)
648
+ lines.push('', 'note: workspace scan was truncated; the change list may be incomplete.');
649
+ return [{ type: 'text', text: lines.join('\n') }];
650
+ }
651
+
652
+ /**
653
+ * Truncate a task string for display.
654
+ *
655
+ * @param task - task text.
656
+ */
657
+ function summarize(task) {
658
+ const flat = String(task ?? '').replace(/\s+/g, ' ').trim();
659
+ return flat.length <= 52 ? flat : `${flat.slice(0, 51)}…`;
660
+ }
661
+
662
+ /**
663
+ * Coerce and validate tool arguments.
664
+ *
665
+ * @param args - raw parsed arguments.
666
+ */
667
+ function normalizeArgs(args, cwd) {
668
+ const input = args !== null && typeof args === 'object' ? args : {};
669
+ const task = typeof input.task === 'string' ? input.task.trim() : '';
670
+ if (task === '')
671
+ throw new Error('cursor_code: `task` is required and must be a non-empty string');
672
+ return {
673
+ task,
674
+ prompt: buildPrompt(task, {
675
+ cwd: input.workspaceRoot ?? cwd ?? '(session working directory)',
676
+ contextFiles: Array.isArray(input.contextFiles)
677
+ ? input.contextFiles.filter(entry => typeof entry === 'string')
678
+ : undefined,
679
+ acceptance: typeof input.acceptance === 'string' ? input.acceptance : undefined,
680
+ }),
681
+ label: typeof input.sessionLabel === 'string' && input.sessionLabel.trim() !== ''
682
+ ? input.sessionLabel.trim()
683
+ : 'default',
684
+ model: typeof input.model === 'string' && input.model.trim() !== '' ? input.model.trim() : undefined,
685
+ mode: input.mode === 'plan' || input.mode === 'ask' || input.mode === 'agent' ? input.mode : undefined,
686
+ continueSession: input.continueSession === true,
687
+ resetSession: input.resetSession === true,
688
+ workspaceRoot: typeof input.workspaceRoot === 'string' && input.workspaceRoot.trim() !== ''
689
+ ? input.workspaceRoot.trim()
690
+ : undefined,
691
+ contextFiles: Array.isArray(input.contextFiles)
692
+ ? input.contextFiles.filter(entry => typeof entry === 'string')
693
+ : undefined,
694
+ acceptance: typeof input.acceptance === 'string' ? input.acceptance : undefined,
695
+ report: input.report === 'detailed' ? 'detailed' : 'summary',
696
+ timeoutSeconds: typeof input.timeoutSeconds === 'number' && Number.isFinite(input.timeoutSeconds)
697
+ ? input.timeoutSeconds
698
+ : undefined,
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Resolve the delegating session's working directory.
704
+ *
705
+ * The `Agent` contract exposes `id`; the live runtime additionally carries a
706
+ * session whose header holds the workspace cwd. Read it defensively — falling
707
+ * back to the process cwd keeps the tool usable rather than failing outright.
708
+ *
709
+ * @param exec - tool run context.
710
+ */
711
+ function resolveCwd(exec) {
712
+ const agent = exec.agent;
713
+ const fromAgent = agent?.session?.header?.cwd;
714
+ if (typeof fromAgent === 'string' && fromAgent !== '')
715
+ return fromAgent;
716
+ const fromContext = exec.session?.header?.cwd;
717
+ if (typeof fromContext === 'string' && fromContext !== '')
718
+ return fromContext;
719
+ return undefined;
720
+ }
721
+
722
+ /**
723
+ * Read a settings value as a usable string.
724
+ *
725
+ * The default-model providers return `''` for "not configured", which must be
726
+ * indistinguishable from "no provider at all": both mean the CLI decides.
727
+ *
728
+ * @param value - candidate string.
729
+ */
730
+ function nonEmpty(value) {
731
+ return typeof value === 'string' && value !== '' ? value : undefined;
732
+ }
733
+
734
+ /**
735
+ * Data directory for the conversation index.
736
+ *
737
+ * @param baseDir - plugin directory reported by Cordis.
738
+ */
739
+ export function resolveDataDir(baseDir) {
740
+ const explicit = process.env.DSH_CURSOR_DEV_DATA;
741
+ if (explicit !== undefined && explicit !== '')
742
+ return explicit;
743
+ const local = process.env.LOCALAPPDATA;
744
+ if (local !== undefined && local !== '')
745
+ return join(local, 'dsh-cursor-dev');
746
+ return join(baseDir ?? process.cwd(), '.data');
747
+ }