dsh-cursor-dev 0.0.0-stage → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/models.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows how a Cursor model choice is named.
|
|
3
|
+
*
|
|
4
|
+
* There are two name spaces in play, and mixing them is what made every model
|
|
5
|
+
* switch fail:
|
|
6
|
+
*
|
|
7
|
+
* 1. The CLI's own listing (`cursor-agent --list-models`) answers with SHORT
|
|
8
|
+
* ALIASES — `auto`, `composer-2.5`, `composer-2.5-fast`, `glm-5.2-high`.
|
|
9
|
+
* This is what the panel stores and what a caller writes by hand.
|
|
10
|
+
* 2. The ACP session roster answers with PARAMETERISED IDS — the alias, its
|
|
11
|
+
* variant options in brackets, and the empty option set spelled `[]`:
|
|
12
|
+
* `default[]`, `composer-2.5[fast=true]`, `kimi-k3[reasoning=max]`.
|
|
13
|
+
* `session/set_config_option` accepts ONLY these; sending the alias is
|
|
14
|
+
* rejected with `-32602 Invalid params: Invalid model value: auto`.
|
|
15
|
+
*
|
|
16
|
+
* So a choice is normalized (alias → the id the protocol speaks) before it is
|
|
17
|
+
* handed to the CLI, and resolved against the live roster so a name the account
|
|
18
|
+
* cannot use is reported before the child is touched.
|
|
19
|
+
*
|
|
20
|
+
* Only ACP's `session/set_config_option` takes these names. The `--model` SPAWN
|
|
21
|
+
* flag is the CLI's own option parser, which wants the bare alias without the
|
|
22
|
+
* bracket suffix — use {@link cliModelArg} for that.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Fallback shown when a name cannot be resolved and no roster is known. */
|
|
26
|
+
export const ALLOWED_FALLBACK_MODEL = 'default[]';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The bare name part of a model id, e.g. `composer-2.5[fast=true]` → `composer-2.5`.
|
|
30
|
+
* @param value - any candidate id.
|
|
31
|
+
*/
|
|
32
|
+
export function baseName(value) {
|
|
33
|
+
if (typeof value !== 'string')
|
|
34
|
+
return '';
|
|
35
|
+
const trimmed = value.trim();
|
|
36
|
+
const bracket = trimmed.indexOf('[');
|
|
37
|
+
return (bracket === -1 ? trimmed : trimmed.slice(0, bracket)).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a model choice into the id the ACP `model` config option speaks.
|
|
42
|
+
*
|
|
43
|
+
* `auto` and the empty string mean "whatever the CLI defaults to", which the
|
|
44
|
+
* roster spells `default[]` — an id no listing ever prints, so it is mapped
|
|
45
|
+
* here rather than looked up.
|
|
46
|
+
*
|
|
47
|
+
* @param value - a panel value, a caller-supplied id, or a configured default.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeModelId(value) {
|
|
50
|
+
if (typeof value !== 'string')
|
|
51
|
+
return '';
|
|
52
|
+
const trimmed = value.trim();
|
|
53
|
+
if (trimmed === '' || trimmed.toLowerCase() === 'auto')
|
|
54
|
+
return ALLOWED_FALLBACK_MODEL;
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The value for the `--model` SPAWN flag, or `''` to omit the flag.
|
|
60
|
+
*
|
|
61
|
+
* The spawn flag takes the alias the CLI itself lists; the bracket suffix and
|
|
62
|
+
* the protocol-only `default[]` alias are both meaningless to it, and the flag
|
|
63
|
+
* is only a startup preference anyway (the session roster decides what a
|
|
64
|
+
* `set_config_option` will accept).
|
|
65
|
+
*
|
|
66
|
+
* @param value - a panel value, a caller-supplied id, or a configured default.
|
|
67
|
+
*/
|
|
68
|
+
export function cliModelArg(value) {
|
|
69
|
+
if (typeof value !== 'string')
|
|
70
|
+
return '';
|
|
71
|
+
const trimmed = value.trim();
|
|
72
|
+
const name = baseName(trimmed);
|
|
73
|
+
if (name === '' || name === 'default' || name.toLowerCase() === 'auto')
|
|
74
|
+
return '';
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Turn the ACP `configOptions` array into the roster shape the pool already
|
|
80
|
+
* reads (`{currentModelId, availableModels: [{modelId, name}]}`).
|
|
81
|
+
*
|
|
82
|
+
* A CLI build that reports models in the legacy `models` shape instead of
|
|
83
|
+
* `configOptions` keeps working: both are reduced to this one shape.
|
|
84
|
+
*
|
|
85
|
+
* @param configOptions - the `configOptions` array of a `session/new` or
|
|
86
|
+
* `session/load` response, or of a `config_option_update` notification.
|
|
87
|
+
* @param current - the id to report as current when the option does not carry one.
|
|
88
|
+
* @returns the roster, or `undefined` when nothing usable was found.
|
|
89
|
+
*/
|
|
90
|
+
export function rosterFromConfigOptions(configOptions, current) {
|
|
91
|
+
if (!Array.isArray(configOptions))
|
|
92
|
+
return undefined;
|
|
93
|
+
const option = configOptions.find(entry => entry !== null && typeof entry === 'object' && entry.id === 'model');
|
|
94
|
+
if (option === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
const available = Array.isArray(option.options)
|
|
97
|
+
? option.options
|
|
98
|
+
.filter(entry => entry !== null && typeof entry === 'object' && typeof entry.value === 'string' && entry.value !== '')
|
|
99
|
+
.map(entry => ({
|
|
100
|
+
modelId: entry.value,
|
|
101
|
+
name: typeof entry.name === 'string' && entry.name !== '' ? entry.name : entry.value,
|
|
102
|
+
}))
|
|
103
|
+
: [];
|
|
104
|
+
const currentModelId = typeof option.currentValue === 'string' && option.currentValue !== ''
|
|
105
|
+
? option.currentValue
|
|
106
|
+
: (typeof current === 'string' ? current : undefined);
|
|
107
|
+
if (available.length === 0 && currentModelId === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
...(currentModelId === undefined ? {} : { currentModelId }),
|
|
111
|
+
...(available.length === 0 ? {} : { availableModels: available }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fold any roster shape (legacy `models`, ACP `configOptions`) into one shape.
|
|
117
|
+
*
|
|
118
|
+
* @param session - a `session/new` / `session/load` response or a pool record.
|
|
119
|
+
* @param fallbackCurrent - id to keep as current when the response omits one.
|
|
120
|
+
*/
|
|
121
|
+
export function rosterFromSession(session, fallbackCurrent) {
|
|
122
|
+
if (session === null || typeof session !== 'object')
|
|
123
|
+
return undefined;
|
|
124
|
+
const fromConfig = rosterFromConfigOptions(session.configOptions, fallbackCurrent);
|
|
125
|
+
const legacy = session.models;
|
|
126
|
+
if (legacy === null || typeof legacy !== 'object') {
|
|
127
|
+
return fromConfig;
|
|
128
|
+
}
|
|
129
|
+
const current = typeof legacy.currentModelId === 'string' && legacy.currentModelId !== ''
|
|
130
|
+
? legacy.currentModelId
|
|
131
|
+
: (fromConfig?.currentModelId ?? fallbackCurrent);
|
|
132
|
+
const fromLegacy = Array.isArray(legacy.availableModels)
|
|
133
|
+
? legacy.availableModels
|
|
134
|
+
.filter(entry => entry !== null && typeof entry === 'object' && typeof entry.modelId === 'string' && entry.modelId !== '')
|
|
135
|
+
.map(entry => ({
|
|
136
|
+
modelId: entry.modelId,
|
|
137
|
+
name: typeof entry.name === 'string' && entry.name !== '' ? entry.name : entry.modelId,
|
|
138
|
+
}))
|
|
139
|
+
: [];
|
|
140
|
+
const available = fromLegacy.length > 0 ? fromLegacy : (fromConfig?.availableModels ?? []);
|
|
141
|
+
if (current === undefined && available.length === 0)
|
|
142
|
+
return fromConfig;
|
|
143
|
+
return {
|
|
144
|
+
...(current === undefined ? {} : { currentModelId: current }),
|
|
145
|
+
...(available.length === 0 ? {} : { availableModels: available }),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The available ids of a roster, in roster order. */
|
|
150
|
+
function availableIds(roster) {
|
|
151
|
+
const list = roster?.availableModels;
|
|
152
|
+
return Array.isArray(list) ? list.map(entry => entry.modelId).filter(id => typeof id === 'string') : [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Find the id a roster would accept for a requested choice.
|
|
157
|
+
*
|
|
158
|
+
* @param modelId - the requested choice (alias, parameterised id, or empty).
|
|
159
|
+
* @param roster - `{currentModelId, availableModels}`, or `undefined` when the
|
|
160
|
+
* build does not report a roster at all.
|
|
161
|
+
* @returns `{ok: true, id, exact, aliased, listed}` with the id to send, or
|
|
162
|
+
* `{ok: false, error, id}` where `id` is the never-listed fallback. `listed`
|
|
163
|
+
* is FALSE only for the no-roster branch below, where the returned `id` is
|
|
164
|
+
* the requested name rather than an id the roster accepts — a caller that
|
|
165
|
+
* compares ids (rather than sending one) must not read those as a match.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveModelId(modelId, roster) {
|
|
168
|
+
const wanted = normalizeModelId(modelId);
|
|
169
|
+
const available = availableIds(roster);
|
|
170
|
+
if (available.length === 0) {
|
|
171
|
+
// No roster to check against: the normalized name is the best guess a
|
|
172
|
+
// caller can make, and the CLI's own rejection carries the real message.
|
|
173
|
+
return { ok: true, id: wanted, exact: true, aliased: false, listed: false };
|
|
174
|
+
}
|
|
175
|
+
if (available.includes(wanted))
|
|
176
|
+
return { ok: true, id: wanted, exact: true, aliased: false, listed: true };
|
|
177
|
+
|
|
178
|
+
const name = baseName(wanted);
|
|
179
|
+
const byName = available.find(id => baseName(id) === name);
|
|
180
|
+
if (byName !== undefined)
|
|
181
|
+
return { ok: true, id: byName, exact: false, aliased: false, listed: true };
|
|
182
|
+
|
|
183
|
+
// The CLI's own listing prefixes its aliases with the workspace vendor
|
|
184
|
+
// (`cursor-grok-4.5-high`) while the session roster publishes the bare family
|
|
185
|
+
// (`grok-4.5[…]`). The prefix is dropped for FAMILY MATCHING ONLY: an exact
|
|
186
|
+
// id is matched verbatim above, so a build that really offers `cursor-…` ids
|
|
187
|
+
// is unaffected, and the id handed back is always a roster id. Both spellings
|
|
188
|
+
// are searched, the roster's own first, so a forked build that publishes
|
|
189
|
+
// `cursor-foo` is matched before its bare `foo` is considered.
|
|
190
|
+
const stripped = name.toLowerCase().startsWith('cursor-') ? name.slice('cursor-'.length) : name;
|
|
191
|
+
const familyNames = stripped === name ? [name] : [name, stripped];
|
|
192
|
+
|
|
193
|
+
// A short alias whose variants are all fully parameterised in the roster.
|
|
194
|
+
// This CLI lists `grok-4.7-xhigh` and `glm-5.2-high` while its session
|
|
195
|
+
// roster offers `grok-4.7[context=256k,…,fast=true]` and
|
|
196
|
+
// `glm-5.2[reasoning=high]` — the alias names a variant the roster spells
|
|
197
|
+
// with brackets, so the two name spaces only meet through the alias's
|
|
198
|
+
// family name (`grok-4.7`). Ambiguity is resolved by the roster's own order
|
|
199
|
+
// (its first entry is the vendor's recommendation) and two candidate
|
|
200
|
+
// families are never guessed between.
|
|
201
|
+
//
|
|
202
|
+
// Only VERSIONED families are tried. A family that has collapsed all the
|
|
203
|
+
// way to the bare vendor (`grok-4.6-xhigh` → `grok`) would match any model
|
|
204
|
+
// of that vendor, quietly turning an alias this account does not offer into
|
|
205
|
+
// one it does; requiring the version segment keeps the match honest.
|
|
206
|
+
const families = familyNames
|
|
207
|
+
.flatMap(familyName => familiesOf(familyName))
|
|
208
|
+
.filter(family => /\d/.test(family));
|
|
209
|
+
|
|
210
|
+
for (const family of families) {
|
|
211
|
+
const under = available.filter(id => id.toLowerCase().startsWith(`${family}[`));
|
|
212
|
+
if (under.length > 0)
|
|
213
|
+
return { ok: true, id: under[0], exact: false, aliased: true, listed: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const family of families) {
|
|
217
|
+
const dashed = available.filter(id => baseName(id).toLowerCase().startsWith(`${family}-`));
|
|
218
|
+
if (dashed.length === 1)
|
|
219
|
+
return { ok: true, id: dashed[0], exact: false, aliased: true, listed: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
id: wanted,
|
|
225
|
+
error: `model "${modelId}" is not offered by this Cursor account; available: ${shortList(available)}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The progressively shorter family names of a CLI alias.
|
|
231
|
+
*
|
|
232
|
+
* `grok-4.7-xhigh` → `['grok-4.7-xhigh', 'grok-4.7']`, so a roster entry named
|
|
233
|
+
* `grok-4.7[…]` can still be found by the alias that pins one of its variants.
|
|
234
|
+
*
|
|
235
|
+
* The walk stops once the next candidate would drop the version: collapsing
|
|
236
|
+
* past `grok-4.7` to `grok` would let an unrelated alias (`cursor-grok-9.9-x`)
|
|
237
|
+
* match this account's `grok-4.7` through nothing but the shared vendor name.
|
|
238
|
+
*
|
|
239
|
+
* @param name - a bare model name.
|
|
240
|
+
*/
|
|
241
|
+
function familiesOf(name) {
|
|
242
|
+
if (name === '')
|
|
243
|
+
return [];
|
|
244
|
+
const families = [name];
|
|
245
|
+
let candidate = name;
|
|
246
|
+
for (let index = candidate.lastIndexOf('-'); index > 0; index = candidate.lastIndexOf('-')) {
|
|
247
|
+
candidate = candidate.slice(0, index);
|
|
248
|
+
if (/^\d/.test(candidate))
|
|
249
|
+
break;
|
|
250
|
+
families.push(candidate);
|
|
251
|
+
}
|
|
252
|
+
return families;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a choice against a roster, returning the flat pieces a caller needs.
|
|
257
|
+
*
|
|
258
|
+
* @param modelId - the requested choice.
|
|
259
|
+
* @param roster - the session roster, or `undefined`.
|
|
260
|
+
*/
|
|
261
|
+
export function resolveModel(modelId, roster) {
|
|
262
|
+
if (typeof modelId !== 'string' || modelId.trim() === '')
|
|
263
|
+
return { id: '', ok: true, exact: true, aliased: false };
|
|
264
|
+
const resolved = resolveModelId(modelId, roster);
|
|
265
|
+
return {
|
|
266
|
+
id: resolved.id,
|
|
267
|
+
ok: resolved.ok,
|
|
268
|
+
exact: resolved.exact,
|
|
269
|
+
aliased: resolved.aliased,
|
|
270
|
+
listed: resolved.listed === true,
|
|
271
|
+
...(resolved.error === undefined ? {} : { error: resolved.error }),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether two choices name the same model, ignoring alias spelling. */
|
|
276
|
+
export function sameModel(left, right) {
|
|
277
|
+
// An absent choice is not a model: `undefined` (no choice) and `''` (an
|
|
278
|
+
// empty choice) are the same non-decision, but neither equals a real model.
|
|
279
|
+
if (!isChoice(left) || !isChoice(right))
|
|
280
|
+
return !isChoice(left) && !isChoice(right);
|
|
281
|
+
const a = normalizeModelId(left);
|
|
282
|
+
const b = normalizeModelId(right);
|
|
283
|
+
if (a === b)
|
|
284
|
+
return true;
|
|
285
|
+
return baseName(a).toLowerCase() === baseName(b).toLowerCase();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Whether a value names a model at all (as opposed to no choice). */
|
|
289
|
+
function isChoice(value) {
|
|
290
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The panel-facing list of a roster, so an unpublished option can be shown as
|
|
295
|
+
* unavailable instead of failing a whole run.
|
|
296
|
+
*
|
|
297
|
+
* @param roster - the session roster.
|
|
298
|
+
*/
|
|
299
|
+
export function listRosterModels(roster) {
|
|
300
|
+
const available = availableIds(roster);
|
|
301
|
+
const current = roster?.currentModelId;
|
|
302
|
+
return available.map(id => ({
|
|
303
|
+
id,
|
|
304
|
+
name: id === ALLOWED_FALLBACK_MODEL ? 'Auto' : baseName(id),
|
|
305
|
+
current: id === current,
|
|
306
|
+
isDefault: id === ALLOWED_FALLBACK_MODEL,
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A comma-separated, bounded rendering of an id list for an error message. */
|
|
311
|
+
function shortList(ids) {
|
|
312
|
+
const head = ids.slice(0, 8).join(', ');
|
|
313
|
+
return ids.length > 8 ? `${head}, … (${ids.length} total)` : head;
|
|
314
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/** Native DSH provider and the cursor_code adapter. The Cursor pool owns idle
|
|
2
|
+
* processes; each published run owns its cancellation and mirrored child lease. */
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { stat } from 'node:fs/promises';
|
|
6
|
+
import { renderCursorReport } from './tool.js';
|
|
7
|
+
import { resolveChildDepth } from './mirror.js';
|
|
8
|
+
|
|
9
|
+
export const CURSOR_PROVIDER = 'cursor';
|
|
10
|
+
|
|
11
|
+
export function cursorStopReason(reason, aborted = false) {
|
|
12
|
+
if (aborted) return 'aborted';
|
|
13
|
+
switch (reason) {
|
|
14
|
+
case 'end_turn': return 'completed';
|
|
15
|
+
case 'cancelled': return 'aborted';
|
|
16
|
+
case 'max_tokens': return 'max-tokens';
|
|
17
|
+
case 'refusal': return 'refusal';
|
|
18
|
+
default: return 'error';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function deferred() {
|
|
23
|
+
let resolve;
|
|
24
|
+
let reject;
|
|
25
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
26
|
+
return { promise, resolve, reject };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function failure(error, cancelled = false) {
|
|
30
|
+
return { ok: false, sessionKey: 'native-start', stopReason: cancelled ? 'cancelled' : 'error',
|
|
31
|
+
durationMs: 0, error: error instanceof Error ? error.message : String(error), steps: [], changes: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Generic native calls use the panel settings and get their own conversation.
|
|
35
|
+
* The cursor_code adapter supplies provider-private arguments for rich controls. */
|
|
36
|
+
export function createCursorProvider({ execute, mirrors, maxChildDepth }) {
|
|
37
|
+
const active = new Set();
|
|
38
|
+
let closed = false;
|
|
39
|
+
return {
|
|
40
|
+
name: CURSOR_PROVIDER,
|
|
41
|
+
inheritsParentContext: false,
|
|
42
|
+
capabilities: Object.freeze({ agentOptions: false, outputSchema: false, depthLimit: false }),
|
|
43
|
+
async start(request) {
|
|
44
|
+
if (closed) throw new Error('cursor: provider is unloading');
|
|
45
|
+
request.signal?.throwIfAborted();
|
|
46
|
+
for (const key of ['agentOptions', 'outputSchema', 'maxDepth', 'toolFilter', 'persona']) {
|
|
47
|
+
if (request[key] !== undefined)
|
|
48
|
+
throw new Error(`cursor: unsupported native capability ${key}`);
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(request.prompt) || request.prompt.length === 0
|
|
51
|
+
|| request.prompt.some(block => block?.type !== 'text' || typeof block.text !== 'string'))
|
|
52
|
+
throw new Error('cursor: native prompts must contain text blocks only');
|
|
53
|
+
const task = request.prompt.map(block => block.text).join('\n\n').trim();
|
|
54
|
+
if (!task) throw new Error('cursor: native prompt must not be empty');
|
|
55
|
+
const originalParent = request.parent;
|
|
56
|
+
// The recursion budget is the plugin's own now that the host leaves it
|
|
57
|
+
// to the provider (`maxDepth: provider-managed`); refuse before any
|
|
58
|
+
// process, child row, or lifecycle event exists.
|
|
59
|
+
if (typeof maxChildDepth === 'number') resolveChildDepth(originalParent, maxChildDepth);
|
|
60
|
+
// dsh >= 0.2.1 starts native providers through the subagents service.
|
|
61
|
+
// That service only accepts an unregistered child id and models such a
|
|
62
|
+
// child as an external row without a local transcript, so the mirror
|
|
63
|
+
// belongs to the cursor_code bridge, which starts the provider directly.
|
|
64
|
+
const viaService = request.cursor === undefined;
|
|
65
|
+
const args = request.cursor?.args ?? { task, sessionLabel: `native-${randomUUID()}` };
|
|
66
|
+
const requestedCwd = typeof request.cwd === 'string' && request.cwd !== ''
|
|
67
|
+
? request.cwd : args.workspaceRoot;
|
|
68
|
+
const cwd = typeof requestedCwd === 'string' && requestedCwd.trim() !== ''
|
|
69
|
+
? requestedCwd.trim() : originalParent?.session?.header?.cwd;
|
|
70
|
+
if (typeof cwd !== 'string' || !isAbsolute(cwd) || !(await stat(cwd)).isDirectory())
|
|
71
|
+
throw new Error('cursor: an accessible absolute parent workspace is required');
|
|
72
|
+
request.signal?.throwIfAborted();
|
|
73
|
+
if (closed) throw new Error('cursor: provider is unloading');
|
|
74
|
+
|
|
75
|
+
const abort = new AbortController();
|
|
76
|
+
const onAbort = () => abort.abort(request.signal?.reason);
|
|
77
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
78
|
+
if (request.signal?.aborted) onAbort();
|
|
79
|
+
const ready = deferred();
|
|
80
|
+
let opened;
|
|
81
|
+
let leasedSession;
|
|
82
|
+
let settled = false;
|
|
83
|
+
let disposal;
|
|
84
|
+
const cursorLifecycle = {
|
|
85
|
+
descriptor: request.descriptor,
|
|
86
|
+
label: request.label,
|
|
87
|
+
acquired(session) { leasedSession = session; },
|
|
88
|
+
...(viaService ? { mirror: false } : {
|
|
89
|
+
opened(entry, key) {
|
|
90
|
+
opened = { entry, key };
|
|
91
|
+
ready.resolve();
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
const execution = Promise.resolve().then(() => execute({ ...args, workspaceRoot: cwd }, {
|
|
96
|
+
agent: originalParent,
|
|
97
|
+
signal: abort.signal,
|
|
98
|
+
cursorLifecycle,
|
|
99
|
+
})).catch(error => failure(error, abort.signal.aborted));
|
|
100
|
+
const cursorReport = execution.then(report => {
|
|
101
|
+
settled = true;
|
|
102
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
103
|
+
if (!viaService && opened === undefined)
|
|
104
|
+
ready.reject(new Error(report.error ?? 'cursor: no child agent was created'));
|
|
105
|
+
return report;
|
|
106
|
+
});
|
|
107
|
+
const result = cursorReport.then(report => {
|
|
108
|
+
const stopReason = cursorStopReason(report.stopReason, abort.signal.aborted);
|
|
109
|
+
return {
|
|
110
|
+
output: renderCursorReport(report),
|
|
111
|
+
stopReason,
|
|
112
|
+
...(stopReason === 'error' || (report.error && stopReason !== 'aborted')
|
|
113
|
+
? { diagnostic: String(report.error ?? `Cursor stopped: ${report.stopReason ?? 'unknown'}`).slice(0, 1000) } : {}),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
const dispose = () => disposal ??= (async () => {
|
|
117
|
+
if (!settled) abort.abort();
|
|
118
|
+
await cursorReport;
|
|
119
|
+
try {
|
|
120
|
+
if (abort.signal.aborted) await leasedSession?.disposeAndWait();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
if (opened !== undefined)
|
|
124
|
+
await mirrors.release(opened.key, { entry: opened.entry, strict: true });
|
|
125
|
+
}
|
|
126
|
+
})().finally(() => active.delete(dispose));
|
|
127
|
+
active.add(dispose);
|
|
128
|
+
// The service owns the child row for external runs; hand it a fresh id
|
|
129
|
+
// and let it observe `result`, without waiting for a mirror child.
|
|
130
|
+
if (viaService)
|
|
131
|
+
return { id: `cursor-${randomUUID()}`, result, cursorReport, dispose };
|
|
132
|
+
try {
|
|
133
|
+
await ready.promise;
|
|
134
|
+
announceMirror(originalParent, opened.entry.child, request.label);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
try { await dispose(); }
|
|
138
|
+
catch (cleanupError) {
|
|
139
|
+
throw new AggregateError([error, cleanupError], `${String(error)}; Cursor cleanup failed: ${String(cleanupError)}`);
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
return { id: opened.entry.child.id ?? opened.entry.child.session.header.id,
|
|
144
|
+
localAgent: undefined, result, cursorReport, dispose };
|
|
145
|
+
},
|
|
146
|
+
async dispose() {
|
|
147
|
+
closed = true;
|
|
148
|
+
const outcomes = await Promise.allSettled([...active].map(dispose => dispose()));
|
|
149
|
+
const errors = outcomes.filter(outcome => outcome.status === 'rejected').map(outcome => outcome.reason);
|
|
150
|
+
if (errors.length) throw new AggregateError(errors, 'cursor: native run disposal failed');
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Cursor executes out of process; the local child is its transcript mirror.
|
|
156
|
+
* The provider owns mirror discovery so continuation has one catalog row. Never
|
|
157
|
+
* proxy the parent: DSH scopeTarget routes events by exact object identity. */
|
|
158
|
+
function announceMirror(parent, child, label) {
|
|
159
|
+
const session = parent.session;
|
|
160
|
+
const header = child.session.header;
|
|
161
|
+
const exists = session.snapshotEvents?.().some(event => event.type === 'subagent/catalog'
|
|
162
|
+
&& event.data?.childId === header.id && event.data?.mode === 'one-shot');
|
|
163
|
+
if (exists) return;
|
|
164
|
+
session.append('subagent/catalog', {
|
|
165
|
+
version: 0, childId: header.id, childCreatedAt: header.createdAt, mode: 'one-shot',
|
|
166
|
+
...(label === undefined ? {} : { label }),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Late-bound native service; absence preserves the existing standalone tool. */
|
|
171
|
+
export function createNativeCursorBridge({ execute, mirrors, maxChildDepth }) {
|
|
172
|
+
let binding;
|
|
173
|
+
return {
|
|
174
|
+
bind(ctx) {
|
|
175
|
+
const runtime = ctx.get?.('subagents');
|
|
176
|
+
if (typeof runtime?.registerProvider !== 'function') return;
|
|
177
|
+
const provider = createCursorProvider({ execute, mirrors, maxChildDepth });
|
|
178
|
+
const unregister = runtime.registerProvider(provider);
|
|
179
|
+
const current = { runtime, provider };
|
|
180
|
+
binding = current;
|
|
181
|
+
ctx.effect(() => async () => {
|
|
182
|
+
if (binding === current) binding = undefined;
|
|
183
|
+
unregister?.();
|
|
184
|
+
await provider.dispose();
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
async execute(args, exec) {
|
|
188
|
+
const current = binding;
|
|
189
|
+
if (current === undefined) return execute(args, exec);
|
|
190
|
+
let run;
|
|
191
|
+
let report;
|
|
192
|
+
try {
|
|
193
|
+
// dsh >= 0.2.1 moved creation to `startActivation` on the service,
|
|
194
|
+
// which only models external children; the bridge owns its mirrored
|
|
195
|
+
// child instead, so it starts the provider directly.
|
|
196
|
+
run = await current.provider.start({
|
|
197
|
+
parent: exec.agent,
|
|
198
|
+
prompt: [{ type: 'text', text: typeof args?.task === 'string' ? args.task : '' }],
|
|
199
|
+
label: args?.sessionLabel || 'Cursor',
|
|
200
|
+
signal: exec.signal,
|
|
201
|
+
cursor: { args },
|
|
202
|
+
});
|
|
203
|
+
await run.result;
|
|
204
|
+
report = await run.cursorReport;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
report = failure(error, exec.signal?.aborted);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await run?.dispose();
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
report = { ...report, ok: false, stopReason: 'error',
|
|
214
|
+
error: `${report.error ? `${report.error}; ` : ''}Cursor cleanup failed: ${String(error)}` };
|
|
215
|
+
}
|
|
216
|
+
return report;
|
|
217
|
+
},
|
|
218
|
+
async dispose() {
|
|
219
|
+
const current = binding;
|
|
220
|
+
binding = undefined;
|
|
221
|
+
await current?.provider.dispose();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt composition for a Cursor turn.
|
|
3
|
+
*
|
|
4
|
+
* The parent model writes the task; this module adds only the scaffolding a
|
|
5
|
+
* coding subagent needs to work autonomously — the workspace it is bound to, a
|
|
6
|
+
* nudge toward its real tools, and the rule that it must finish rather than ask
|
|
7
|
+
* questions (there is no human on the child's end of the pipe).
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT done here: no persona override, no hidden system prompt. The
|
|
10
|
+
* parent's task text is passed through verbatim so the parent stays in control
|
|
11
|
+
* of what the child does.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the prompt sent to the Cursor child.
|
|
16
|
+
*
|
|
17
|
+
* @param task - the parent-authored task text (verbatim).
|
|
18
|
+
* @param options - workspace and context attachments.
|
|
19
|
+
*/
|
|
20
|
+
export function buildPrompt(task, options) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
parts.push(`You are working in this workspace: ${options.cwd}`);
|
|
23
|
+
parts.push('');
|
|
24
|
+
parts.push('## Task');
|
|
25
|
+
parts.push(task.trim());
|
|
26
|
+
|
|
27
|
+
if (options.acceptance !== undefined && options.acceptance.trim() !== '') {
|
|
28
|
+
parts.push('');
|
|
29
|
+
parts.push('## Acceptance criteria');
|
|
30
|
+
parts.push(options.acceptance.trim());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.contextFiles !== undefined && options.contextFiles.length > 0) {
|
|
34
|
+
parts.push('');
|
|
35
|
+
parts.push('## Files to read first');
|
|
36
|
+
for (const path of options.contextFiles)
|
|
37
|
+
parts.push(`- ${path}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
parts.push('');
|
|
41
|
+
parts.push('## How to work');
|
|
42
|
+
parts.push('- Use your own file-editing and shell tools to make the changes on disk. Do not just describe them.');
|
|
43
|
+
parts.push('- Read the surrounding code before editing so the change matches local conventions.');
|
|
44
|
+
parts.push('- You cannot ask questions: nobody is available to answer. Make the most reasonable choice, do the work, and state any assumption in your final message.');
|
|
45
|
+
parts.push('- When you are done, reply with a concise report: what you changed (file paths), what you verified, and anything left undone.');
|
|
46
|
+
|
|
47
|
+
return parts.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One-line label for the child run, used in status output.
|
|
52
|
+
*
|
|
53
|
+
* @param task - parent task text.
|
|
54
|
+
* @param maxLength - truncation length.
|
|
55
|
+
*/
|
|
56
|
+
export function summarizeTask(task, maxLength = 60) {
|
|
57
|
+
const flat = task.replace(/\s+/g, ' ').trim();
|
|
58
|
+
return flat.length <= maxLength ? flat : `${flat.slice(0, maxLength - 1)}…`;
|
|
59
|
+
}
|