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/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
+ }