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/command.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cursor-dev — the `/cursor-subagent` human command.
|
|
3
|
+
*
|
|
4
|
+
* The split this command has to keep straight: **Cursor writes the business
|
|
5
|
+
* code, the parent (harness) model reviews it**. So the command does not ask the
|
|
6
|
+
* parent to delegate — asking is exactly how the parent ends up writing the code
|
|
7
|
+
* itself. It dispatches the task to the child directly:
|
|
8
|
+
*
|
|
9
|
+
* /cursor-subagent 把 oldcat 的存档格式换成 JSON
|
|
10
|
+
*
|
|
11
|
+
* 1. the handler starts the child run right away and settles, so the composer
|
|
12
|
+
* is free while Cursor works (the panel shows the live session);
|
|
13
|
+
* 2. when the run settles, one ordinary user message is steered into the same
|
|
14
|
+
* conversation carrying the facts a review needs — what Cursor changed,
|
|
15
|
+
* which model ran, its own summary;
|
|
16
|
+
* 3. the parent model wakes on that message and reviews the diff instead of
|
|
17
|
+
* authoring it.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here reaches the model as a *tool* call: the run is the same one
|
|
20
|
+
* `cursor_code` performs (same pool, same journal, same conversation record), so
|
|
21
|
+
* a follow-up `cursor_code` with `continueSession: true` continues the very same
|
|
22
|
+
* Cursor conversation the command started.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-cursor-dev/command
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
/** The registered command name, without the leading slash. */
|
|
29
|
+
export const COMMAND_NAME = 'cursor-subagent';
|
|
30
|
+
|
|
31
|
+
/** Shown when the command is run without a task, in both languages. */
|
|
32
|
+
export const USAGE = '用法:/cursor-subagent [-m <模型>] <要交给 Cursor 写的任务> · Usage: /cursor-subagent [-m <model>] <task>';
|
|
33
|
+
|
|
34
|
+
/** How much of the child's own summary the review message carries. */
|
|
35
|
+
const SUMMARY_LIMIT = 1200;
|
|
36
|
+
|
|
37
|
+
/** How many changed files the review message lists. */
|
|
38
|
+
const FILE_LIMIT = 20;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split the command line into an optional model override and the task.
|
|
42
|
+
*
|
|
43
|
+
* Only a leading `-m <model>` is special; everything after it is the task, so a
|
|
44
|
+
* task may contain dashes, quotes, or newlines without being reinterpreted.
|
|
45
|
+
*
|
|
46
|
+
* @param rawInput - everything the user typed after the command name.
|
|
47
|
+
* @returns `{ model?, task? }`; both absent when the line was empty.
|
|
48
|
+
*/
|
|
49
|
+
export function parseCommandInput(rawInput) {
|
|
50
|
+
const text = String(rawInput ?? '').trim();
|
|
51
|
+
if (text === '')
|
|
52
|
+
return {};
|
|
53
|
+
const match = /^-m\s+(\S+)(?:\s+([\s\S]*))?$/.exec(text);
|
|
54
|
+
if (match === null)
|
|
55
|
+
return { task: text };
|
|
56
|
+
const task = (match[2] ?? '').trim();
|
|
57
|
+
if (task === '')
|
|
58
|
+
return { model: match[1] };
|
|
59
|
+
return { model: match[1], task };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** First line of the task, for the settled-command line the UI renders. */
|
|
63
|
+
function firstLine(text) {
|
|
64
|
+
const line = String(text ?? '').split('\n', 1)[0].trim();
|
|
65
|
+
return line.length > 60 ? `${line.slice(0, 57)}…` : line;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Clamp a block of text to a budget, saying how much was dropped. */
|
|
69
|
+
function clamp(text, limit) {
|
|
70
|
+
const value = String(text ?? '').trim();
|
|
71
|
+
if (value.length <= limit)
|
|
72
|
+
return value;
|
|
73
|
+
return `${value.slice(0, limit)}…(还有 ${value.length - limit} 字,完整内容在面板里)`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One `path (modified +12/-3)` line per change, or a plain "no edits". */
|
|
77
|
+
function changeLines(result) {
|
|
78
|
+
const changes = Array.isArray(result?.changes) ? result.changes : [];
|
|
79
|
+
if (changes.length === 0)
|
|
80
|
+
return ['(这次没有落盘的文件改动)'];
|
|
81
|
+
const shown = changes.slice(0, FILE_LIMIT).map(change => {
|
|
82
|
+
const counts = typeof change.added === 'number' || typeof change.removed === 'number'
|
|
83
|
+
? ` +${change.added ?? 0}/-${change.removed ?? 0}`
|
|
84
|
+
: '';
|
|
85
|
+
return `· ${change.path}(${change.operation ?? 'modified'}${counts})`;
|
|
86
|
+
});
|
|
87
|
+
if (changes.length > FILE_LIMIT)
|
|
88
|
+
shown.push(`· …还有 ${changes.length - FILE_LIMIT} 个文件`);
|
|
89
|
+
return shown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The message that hands the finished run to the parent model for review.
|
|
94
|
+
*
|
|
95
|
+
* It states the division of labour outright, because a review message that only
|
|
96
|
+
* reports a result invites the parent to redo the work instead of checking it.
|
|
97
|
+
*
|
|
98
|
+
* @param task - the task the user gave the command.
|
|
99
|
+
* @param result - the `cursor_code` result object.
|
|
100
|
+
* @param options - `toolName`.
|
|
101
|
+
* @returns the user-role message text.
|
|
102
|
+
*/
|
|
103
|
+
export function reviewBrief(task, result, options = {}) {
|
|
104
|
+
const toolName = options.toolName ?? 'cursor_code';
|
|
105
|
+
const ok = result?.ok === true;
|
|
106
|
+
const seconds = typeof result?.durationMs === 'number' ? `${Math.round(result.durationMs / 1000)}s` : '?';
|
|
107
|
+
const lines = [
|
|
108
|
+
'Cursor 子智能体已经动手写完了,现在轮到你审查它的代码(不要自己重写一遍;要改就再让它改):',
|
|
109
|
+
'',
|
|
110
|
+
`任务:${firstLine(task)}`,
|
|
111
|
+
`结果:${ok ? '跑完了' : '没跑完'} · 模型 ${result?.model ?? '?'} · 用时 ${seconds} · 步数 ${Array.isArray(result?.steps) ? result.steps.length : 0}`,
|
|
112
|
+
`改动 ${Array.isArray(result?.changes) ? result.changes.length : 0} 个文件:`,
|
|
113
|
+
...changeLines(result),
|
|
114
|
+
];
|
|
115
|
+
const summary = clamp(result?.text, SUMMARY_LIMIT);
|
|
116
|
+
if (summary !== '')
|
|
117
|
+
lines.push('', `它自己的说明:${summary}`);
|
|
118
|
+
if (result?.error !== undefined && result.error !== null)
|
|
119
|
+
lines.push('', `错误:${clamp(result.error, 600)}`);
|
|
120
|
+
lines.push(
|
|
121
|
+
'',
|
|
122
|
+
'你该做的:核对 diff 与它改过的文件、跑测试或按验收标准验证;发现问题就用 '
|
|
123
|
+
+ `\`${toolName}\` 带 \`continueSession: true\` 让它接着改(同一个 Cursor 会话,它记得自己写过什么)。`,
|
|
124
|
+
'完整 diff、逐步时间线与额度在面板:设置 → Cursor 子智能体。',
|
|
125
|
+
);
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The message for a run that threw instead of returning a result.
|
|
131
|
+
*
|
|
132
|
+
* @param task - the task the user gave the command.
|
|
133
|
+
* @param error - whatever the runner threw.
|
|
134
|
+
* @returns the user-role message text.
|
|
135
|
+
*/
|
|
136
|
+
export function failureBrief(task, error) {
|
|
137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
138
|
+
return [
|
|
139
|
+
'Cursor 子智能体这次没能跑起来(不是它的代码产出,而是运行本身失败),由你判断怎么收场:',
|
|
140
|
+
'',
|
|
141
|
+
`任务:${firstLine(task)}`,
|
|
142
|
+
`错误:${clamp(message, 600)}`,
|
|
143
|
+
'',
|
|
144
|
+
'可以先用面板确认 CLI 是否可用/已登录,再决定重派一次,还是你自己处理。',
|
|
145
|
+
].join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One identified, immutable user message.
|
|
150
|
+
*
|
|
151
|
+
* `createUserMessage` lives in a first-party package that a profile-installed
|
|
152
|
+
* plugin cannot resolve, and all it does is stamp an id, a role, and a freeze
|
|
153
|
+
* onto the input — so the shape is built here instead.
|
|
154
|
+
*
|
|
155
|
+
* @param content - message content blocks.
|
|
156
|
+
* @returns a frozen user message.
|
|
157
|
+
*/
|
|
158
|
+
export function userMessage(content) {
|
|
159
|
+
return deepFreeze({ id: randomUUID(), role: 'user', content, source: { kind: 'user' } });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Freeze a message the way the session log expects to receive it. */
|
|
163
|
+
function deepFreeze(value) {
|
|
164
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
165
|
+
Object.freeze(value);
|
|
166
|
+
for (const key of Object.keys(value))
|
|
167
|
+
deepFreeze(value[key]);
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Run the child and hand the outcome to the parent for review.
|
|
174
|
+
*
|
|
175
|
+
* Never throws: a failure has to reach the conversation as a message, or the
|
|
176
|
+
* user is left with a run that vanished. The parent is woken by the steer, so
|
|
177
|
+
* this is what turns "Cursor wrote code" into "the parent reviews code".
|
|
178
|
+
*
|
|
179
|
+
* @param options - `run` ({@link ChildRunner}), `agent`, `task`, `model`, `logger`, `toolName`.
|
|
180
|
+
* @returns the child result, or undefined when the run threw.
|
|
181
|
+
*/
|
|
182
|
+
export async function runChildForReview(options) {
|
|
183
|
+
const { run, agent, task, logger } = options;
|
|
184
|
+
try {
|
|
185
|
+
const result = await run({
|
|
186
|
+
task,
|
|
187
|
+
model: options.model,
|
|
188
|
+
agent,
|
|
189
|
+
signal: options.signal,
|
|
190
|
+
timeoutSeconds: options.timeoutSeconds,
|
|
191
|
+
});
|
|
192
|
+
agent.steer(userMessage([{ type: 'text', text: reviewBrief(task, result, options) }]));
|
|
193
|
+
logger?.info?.(`dsh-cursor-dev: /${COMMAND_NAME} finished "${firstLine(task)}" ok=${result?.ok === true}`);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
198
|
+
logger?.warn?.(`dsh-cursor-dev: /${COMMAND_NAME} run failed: ${message}`);
|
|
199
|
+
try {
|
|
200
|
+
agent.steer(userMessage([{ type: 'text', text: failureBrief(task, error) }]));
|
|
201
|
+
}
|
|
202
|
+
catch (steerError) {
|
|
203
|
+
// The session is gone; the log line above is the only record left.
|
|
204
|
+
logger?.warn?.(`dsh-cursor-dev: could not report the failed run: ${steerError instanceof Error ? steerError.message : String(steerError)}`);
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Register `/cursor-subagent` against the host command registry.
|
|
212
|
+
*
|
|
213
|
+
* The dependency is injected rather than declared in the plugin's `inject`
|
|
214
|
+
* list: a headless profile has no command surface at all, and the coding tool
|
|
215
|
+
* must not go down with it. A host with no `commands` service simply never runs
|
|
216
|
+
* the mount.
|
|
217
|
+
*
|
|
218
|
+
* @param ctx - Cordis context of the plugin.
|
|
219
|
+
* @param options - `toolName`, `logger`, and `run` (the child runner).
|
|
220
|
+
*/
|
|
221
|
+
export function registerCursorCommand(ctx, options = {}) {
|
|
222
|
+
const toolName = options.toolName ?? 'cursor_code';
|
|
223
|
+
const runner = options.run;
|
|
224
|
+
const mount = (commandCtx) => {
|
|
225
|
+
const commands = typeof commandCtx.get === 'function' ? commandCtx.get('commands') : undefined;
|
|
226
|
+
// No command surface is a normal host here (a headless profile), not a
|
|
227
|
+
// problem to report: the coding tool is unaffected, so this stays quiet.
|
|
228
|
+
if (commands === undefined || typeof commands.register !== 'function')
|
|
229
|
+
return;
|
|
230
|
+
commandCtx.effect(() => {
|
|
231
|
+
const dispose = commands.register({
|
|
232
|
+
name: COMMAND_NAME,
|
|
233
|
+
description: '直接把活儿交给 Cursor 子智能体写,写完由你审查 · Hand the task straight to the Cursor CLI child agent; the parent reviews',
|
|
234
|
+
input: { hint: '[-m <模型>] <任务>' },
|
|
235
|
+
handler: ({ agent, rawInput }) => {
|
|
236
|
+
const { model, task } = parseCommandInput(rawInput);
|
|
237
|
+
if (task === undefined)
|
|
238
|
+
return { kind: 'error', text: USAGE };
|
|
239
|
+
if (agent === undefined || typeof agent.steer !== 'function')
|
|
240
|
+
return { kind: 'error', text: '这个命令要在有会话的地方用 · this command needs a live session' };
|
|
241
|
+
if (typeof runner !== 'function')
|
|
242
|
+
return { kind: 'error', text: '这个 host 上没有接上 Cursor 运行器 · no child runner is wired on this host' };
|
|
243
|
+
// Deliberately not the invocation's own signal: that one is
|
|
244
|
+
// aborted as soon as this handler settles, which would kill the
|
|
245
|
+
// run a second later. The child's own timeout bounds it.
|
|
246
|
+
const controller = new AbortController();
|
|
247
|
+
void runChildForReview({
|
|
248
|
+
run: runner,
|
|
249
|
+
agent,
|
|
250
|
+
task,
|
|
251
|
+
model,
|
|
252
|
+
signal: controller.signal,
|
|
253
|
+
logger: options.logger,
|
|
254
|
+
toolName,
|
|
255
|
+
});
|
|
256
|
+
options.logger?.info?.(`dsh-cursor-dev: /${COMMAND_NAME} dispatched to the child "${firstLine(task)}"`);
|
|
257
|
+
return { kind: 'success', text: `已交给 Cursor 子智能体去写,写完把结果交给主模型审查:${firstLine(task)}` };
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return () => {
|
|
261
|
+
dispose?.();
|
|
262
|
+
};
|
|
263
|
+
}, `dsh-cursor-dev: /${COMMAND_NAME} command`);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (typeof ctx.inject !== 'function') {
|
|
267
|
+
mount(ctx);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
ctx.inject(['commands'], mount);
|
|
271
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable conversation index.
|
|
3
|
+
*
|
|
4
|
+
* The live pool holds processes; this file holds the *identity* of each
|
|
5
|
+
* conversation so a later run can tell the difference between "this key never
|
|
6
|
+
* existed" and "this key existed but its process is gone" — and, when Cursor
|
|
7
|
+
* supports it, attempt a cold resume instead of silently starting over.
|
|
8
|
+
*
|
|
9
|
+
* Written with a temp-file + rename so a crash mid-write cannot corrupt it.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Small JSON-backed map of pool key -> conversation identity.
|
|
16
|
+
*/
|
|
17
|
+
export class ConversationIndex {
|
|
18
|
+
#path;
|
|
19
|
+
#entries = new Map();
|
|
20
|
+
#loaded = false;
|
|
21
|
+
#writeChain = Promise.resolve();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param dataDir - plugin state directory.
|
|
25
|
+
*/
|
|
26
|
+
constructor(dataDir) {
|
|
27
|
+
this.#path = join(dataDir, 'conversations.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute path of the backing file. */
|
|
31
|
+
get path() {
|
|
32
|
+
return this.#path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Load from disk once. A corrupt file is discarded, not fatal. */
|
|
36
|
+
async load() {
|
|
37
|
+
if (this.#loaded)
|
|
38
|
+
return;
|
|
39
|
+
this.#loaded = true;
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(this.#path, 'utf8');
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
44
|
+
for (const [key, value] of Object.entries(parsed))
|
|
45
|
+
this.#entries.set(key, value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Missing or unreadable: start empty.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read one entry.
|
|
55
|
+
*
|
|
56
|
+
* @param key - pool key.
|
|
57
|
+
*/
|
|
58
|
+
get(key) {
|
|
59
|
+
return this.#entries.get(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Insert or update one entry and persist.
|
|
64
|
+
*
|
|
65
|
+
* @param key - pool key.
|
|
66
|
+
* @param entry - conversation identity record.
|
|
67
|
+
*/
|
|
68
|
+
async set(key, entry) {
|
|
69
|
+
this.#entries.set(key, entry);
|
|
70
|
+
await this.#flush();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove one entry and persist.
|
|
75
|
+
*
|
|
76
|
+
* @param key - pool key.
|
|
77
|
+
*/
|
|
78
|
+
async delete(key) {
|
|
79
|
+
if (this.#entries.delete(key))
|
|
80
|
+
await this.#flush();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every live index entry. */
|
|
84
|
+
list() {
|
|
85
|
+
return [...this.#entries.entries()].map(([key, value]) => ({ key, ...value }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Persist atomically, serialized so concurrent writes cannot interleave. */
|
|
89
|
+
async #flush() {
|
|
90
|
+
const payload = JSON.stringify(Object.fromEntries(this.#entries), null, 2);
|
|
91
|
+
this.#writeChain = this.#writeChain.then(async () => {
|
|
92
|
+
await mkdir(dirname(this.#path), { recursive: true });
|
|
93
|
+
const temporary = `${this.#path}.tmp`;
|
|
94
|
+
await writeFile(temporary, payload, 'utf8');
|
|
95
|
+
await rename(temporary, this.#path);
|
|
96
|
+
}).catch(() => {
|
|
97
|
+
// A failed index write must not fail the coding task itself.
|
|
98
|
+
});
|
|
99
|
+
await this.#writeChain;
|
|
100
|
+
}
|
|
101
|
+
}
|