dsh-cursor-dev 0.0.0-stage → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +263 -2
- package/cordis.patch.yml +12 -0
- package/docs/INSTALL.md +219 -0
- package/lib/changes.js +192 -0
- package/lib/cli.js +237 -0
- package/lib/client.js +626 -0
- package/lib/command.js +271 -0
- package/lib/conversations.js +101 -0
- package/lib/cursor-session.js +871 -0
- package/lib/fence.js +123 -0
- package/lib/index.js +477 -0
- package/lib/journal.js +276 -0
- package/lib/mirror.js +1293 -0
- package/lib/models.js +314 -0
- package/lib/native-provider.js +224 -0
- package/lib/prompt.js +59 -0
- package/lib/proxy.js +414 -0
- package/lib/resolve-agent.js +251 -0
- package/lib/session-pool.js +927 -0
- package/lib/settings.js +642 -0
- package/lib/tool.js +747 -0
- package/lib/usage.js +2262 -0
- package/package.json +60 -4
- package/screenshots/panel-zh-settings.png +0 -0
- package/screenshots/panel-zh.png +0 -0
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
|
+
}
|