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/fence.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Host/Origin fence every plugin route runs behind.
|
|
3
|
+
*
|
|
4
|
+
* A `webServer` route is served **outside** the host's own `/api` carrier, so
|
|
5
|
+
* the host's browser-authentication middleware never sees it. Without a fence
|
|
6
|
+
* of its own, a page on any other origin could POST to
|
|
7
|
+
* `http://127.0.0.1:<port>/plugins/dsh-cursor-dev/api/settings` and rewrite the
|
|
8
|
+
* operator's plugin settings — a request the browser sends happily because the
|
|
9
|
+
* listener is loopback and the response is not read (a CSRF write). The same
|
|
10
|
+
* hole would let a foreign page read `/status` and `/api/usage`.
|
|
11
|
+
*
|
|
12
|
+
* Two layers, in order:
|
|
13
|
+
*
|
|
14
|
+
* 1. `ctx.connection.requestRejection(req)` — the host's own check, the exact
|
|
15
|
+
* function its `/api` carrier uses: the `Host` must be loopback (or a
|
|
16
|
+
* configured trusted authority), `Sec-Fetch-Site: cross-site` is refused, and
|
|
17
|
+
* a present `Origin` must match the request's `Host`.
|
|
18
|
+
* 2. A local fallback with the same rules, used when the `connection` service is
|
|
19
|
+
* absent (a headless host that mounted a webServer without a browser
|
|
20
|
+
* carrier). Refusing a browser-shaped cross-site request costs a local curl
|
|
21
|
+
* nothing, so this degrades safely.
|
|
22
|
+
*
|
|
23
|
+
* Only the host's **403** verdict is adopted, never its **401**: these routes
|
|
24
|
+
* are deliberately readable without a browser session (that is how the panel is
|
|
25
|
+
* screenshotted and how the standalone dashboard URL works), so this fence adds
|
|
26
|
+
* a cross-site refusal without ever adding an authentication requirement. The
|
|
27
|
+
* host's 403 also carries its trusted-authority list, which a hand-rolled check
|
|
28
|
+
* would have to duplicate.
|
|
29
|
+
*
|
|
30
|
+
* @param ctx - Cordis context (the same one the routes are mounted on).
|
|
31
|
+
* @returns a `(req, res) => boolean` gate: false means the response is already
|
|
32
|
+
* written and the handler must not run.
|
|
33
|
+
*/
|
|
34
|
+
export function createRouteFence(ctx) {
|
|
35
|
+
return (req, res) => {
|
|
36
|
+
const rejection = rejectViaConnection(ctx, req) ?? rejectLocally(req);
|
|
37
|
+
if (rejection === undefined)
|
|
38
|
+
return true;
|
|
39
|
+
sendRejection(res, rejection);
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ask the host's connection service, when this host has one.
|
|
46
|
+
*
|
|
47
|
+
* @returns 403 when the host itself refuses the request as cross-site, or
|
|
48
|
+
* undefined for anything else — including a 401, which says the host
|
|
49
|
+
* wants a browser session this plugin deliberately does not require.
|
|
50
|
+
*/
|
|
51
|
+
function rejectViaConnection(ctx, req) {
|
|
52
|
+
let connection;
|
|
53
|
+
try {
|
|
54
|
+
connection = ctx?.get?.('connection');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// A service lookup that throws is the same as one that is absent.
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (typeof connection?.requestRejection !== 'function')
|
|
61
|
+
return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return connection.requestRejection({ headers: req?.headers ?? {} }) === 403 ? 403 : undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// A broken host check must fail CLOSED for a browser request: fall back
|
|
67
|
+
// to the local rules rather than waving everything through.
|
|
68
|
+
return rejectLocally(req);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The fence used when the host exposes no connection service.
|
|
74
|
+
*
|
|
75
|
+
* This is deliberately the browser-shaped half of the host's own rule: a
|
|
76
|
+
* request that a cross-origin page could have produced is refused, and a
|
|
77
|
+
* request from a tool (no `Origin`, no `Sec-Fetch-Site`) is allowed. It cannot
|
|
78
|
+
* check the session cookie, so it is a fallback, not a replacement.
|
|
79
|
+
*/
|
|
80
|
+
function rejectLocally(req) {
|
|
81
|
+
const headers = req?.headers ?? {};
|
|
82
|
+
const fetchSite = header(headers, 'sec-fetch-site');
|
|
83
|
+
if (fetchSite === 'cross-site')
|
|
84
|
+
return 403;
|
|
85
|
+
const origin = header(headers, 'origin');
|
|
86
|
+
if (origin === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const host = header(headers, 'host');
|
|
89
|
+
if (host === undefined)
|
|
90
|
+
return 403;
|
|
91
|
+
try {
|
|
92
|
+
return new URL(origin).host === new URL(`http://${host}`).host ? undefined : 403;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return 403;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read one header as a plain string, tolerating the Fetch `Headers` shape. */
|
|
100
|
+
function header(headers, name) {
|
|
101
|
+
if (typeof headers?.get === 'function')
|
|
102
|
+
return headers.get(name) ?? undefined;
|
|
103
|
+
const value = headers[name];
|
|
104
|
+
if (typeof value === 'string')
|
|
105
|
+
return value;
|
|
106
|
+
if (Array.isArray(value) && typeof value[0] === 'string')
|
|
107
|
+
return value[0];
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Answer a refused request the way the host's own carrier does. */
|
|
112
|
+
function sendRejection(res, status) {
|
|
113
|
+
try {
|
|
114
|
+
res.writeHead(status, {
|
|
115
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
116
|
+
'cache-control': 'no-store',
|
|
117
|
+
});
|
|
118
|
+
res.end('this request did not come from the DSH page.\n');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// The socket is already gone; nothing left to answer.
|
|
122
|
+
}
|
|
123
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cursor-dev — run the Cursor CLI as a *child coding agent* under a DSH
|
|
3
|
+
* parent model.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists beside `@deepseek-ai/dsh-subagent-acp` / `dsh-cursor-acp`:
|
|
6
|
+
* the stock ACP provider is one-shot and capability-free by construction — it
|
|
7
|
+
* advertises `agentOptions: false`, implements only `start()`, drops every
|
|
8
|
+
* `session/update` it does not fold into final message text, and spawns a new
|
|
9
|
+
* process per call. That makes per-task model choice, conversation continuity,
|
|
10
|
+
* and progress visibility impossible.
|
|
11
|
+
*
|
|
12
|
+
* This plugin talks ACP directly instead, so it can keep a session alive across
|
|
13
|
+
* turns, switch models per call, and report reasoning, tool calls, and the real
|
|
14
|
+
* file diffs back to the parent.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-cursor-dev
|
|
17
|
+
*/
|
|
18
|
+
import z from '@deepseek-ai/schemastery';
|
|
19
|
+
import { resolveAgent } from './resolve-agent.js';
|
|
20
|
+
import { CLI_SETUP, authStatus, describeAgent } from './cli.js';
|
|
21
|
+
import { SessionPool } from './session-pool.js';
|
|
22
|
+
import { RunJournal } from './journal.js';
|
|
23
|
+
import { ConversationIndex } from './conversations.js';
|
|
24
|
+
import { cursorCodeTool, resolveDataDir } from './tool.js';
|
|
25
|
+
import { registerCursorCommand } from './command.js';
|
|
26
|
+
import { createMirrorHub } from './mirror.js';
|
|
27
|
+
import { createRouteFence } from './fence.js';
|
|
28
|
+
import { UsageClient, registerUsageRoutes } from './usage.js';
|
|
29
|
+
import { effectiveModel, readSettings, registerSettingsRoutes, resolveModelSelection } from './settings.js';
|
|
30
|
+
import { createNativeCursorBridge } from './native-provider.js';
|
|
31
|
+
import { describeProxy, proxySettingIsDirect, resolveChildProxy, withProxyEnvironment } from './proxy.js';
|
|
32
|
+
|
|
33
|
+
export const name = 'dsh-cursor-dev';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Services this plugin requires; a missing one keeps the plugin inactive.
|
|
37
|
+
*
|
|
38
|
+
* `webServer` is deliberately NOT listed here: the coding tool is the point of
|
|
39
|
+
* this plugin and must stay active even in a headless profile. The HTTP
|
|
40
|
+
* surfaces still need it, so they are mounted in their own dependency scope
|
|
41
|
+
* (see {@link mountHttpSurfaces}) instead of being read out of this fiber.
|
|
42
|
+
*/
|
|
43
|
+
export const inject = ['tools', 'subprocess'];
|
|
44
|
+
|
|
45
|
+
export const Config = z.object({
|
|
46
|
+
/** Model passed to the child at spawn (`composer-2.5`, `grok-4.7`, …). */
|
|
47
|
+
model: z.string().default(''),
|
|
48
|
+
/**
|
|
49
|
+
* Absolute path that overrides Cursor CLI auto-discovery. Accepts a
|
|
50
|
+
* spawnable binary, an install root, an `agent.cmd` shim, or
|
|
51
|
+
* `"node.exe" "index.js"`. Empty string keeps PATH / known-dir probing.
|
|
52
|
+
*/
|
|
53
|
+
command: z.string().default(''),
|
|
54
|
+
/** Live Cursor processes kept in the pool. */
|
|
55
|
+
maxLiveSessions: z.number().default(3),
|
|
56
|
+
/** Idle minutes before a pooled conversation is reaped. */
|
|
57
|
+
sessionIdleMinutes: z.number().default(15),
|
|
58
|
+
/** Files examined per workspace fingerprint. */
|
|
59
|
+
maxScanFiles: z.number().default(20000),
|
|
60
|
+
/** Register the `cursor_code` tool. */
|
|
61
|
+
toolName: z.string().default('cursor_code'),
|
|
62
|
+
/** Cursor API host for the usage RPC. */
|
|
63
|
+
usageBaseUrl: z.string().default('https://api2.cursor.sh'),
|
|
64
|
+
/** Disable the usage routes entirely (the coding tool is unaffected). */
|
|
65
|
+
usageUi: z.boolean().default(true),
|
|
66
|
+
/**
|
|
67
|
+
* Recursion budget for Cursor children. `1` (the platform default for
|
|
68
|
+
* delegation tools) allows only a top-level agent to start a Cursor run;
|
|
69
|
+
* `0` refuses Cursor children entirely; a higher number allows a subagent to
|
|
70
|
+
* start one too. Raise it deliberately — every level multiplies cost.
|
|
71
|
+
*/
|
|
72
|
+
maxChildDepth: z.natural().default(1),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mount the plugin.
|
|
77
|
+
*
|
|
78
|
+
* @param ctx - Cordis context.
|
|
79
|
+
* @param config - resolved plugin config.
|
|
80
|
+
*/
|
|
81
|
+
export function apply(ctx, config) {
|
|
82
|
+
const logger = ctx.logger;
|
|
83
|
+
|
|
84
|
+
// A missing CLI must not take the whole plugin down. The usage dashboard,
|
|
85
|
+
// the status route and the journal are still useful without it, and a failed
|
|
86
|
+
// fiber hides the reason — the tool reports the problem instead. The message
|
|
87
|
+
// is the setup path itself, because that is the first thing a new user hits.
|
|
88
|
+
const commandOverride = typeof config.command === 'string' ? config.command.trim() : '';
|
|
89
|
+
const resolved = resolveAgent(process.env, { command: commandOverride });
|
|
90
|
+
const unavailable = resolved === undefined
|
|
91
|
+
? (commandOverride !== ''
|
|
92
|
+
? `dsh-cursor-dev: configured command \`${commandOverride}\` is not a usable Cursor CLI. `
|
|
93
|
+
+ 'Set `command` to a spawnable binary, an install root, an `agent.cmd` shim, '
|
|
94
|
+
+ 'or `"node.exe" "index.js"`, then restart DSH.'
|
|
95
|
+
: 'dsh-cursor-dev: the Cursor CLI was not found. Install it with '
|
|
96
|
+
+ `\`${CLI_SETUP.install.win32}\` (Windows PowerShell) or \`${CLI_SETUP.install.unix}\` (macOS/Linux), `
|
|
97
|
+
+ `run \`${CLI_SETUP.login}\`, then restart DSH — or point \`command\` in the plugin config at the CLI entry point.`)
|
|
98
|
+
: undefined;
|
|
99
|
+
if (unavailable !== undefined)
|
|
100
|
+
logger?.warn?.(unavailable);
|
|
101
|
+
else
|
|
102
|
+
logger?.info?.(`dsh-cursor-dev: using Cursor CLI at ${resolved.command} (${resolved.source})`);
|
|
103
|
+
|
|
104
|
+
// `baseDir` is a Cordis service: reading it as a property throws
|
|
105
|
+
// `cannot get property "baseDir" without inject`. Go through `get()` so the
|
|
106
|
+
// plugin still mounts on a host that does not provide it.
|
|
107
|
+
const dataDir = resolveDataDir(ctx.get('baseDir'));
|
|
108
|
+
const index = new ConversationIndex(dataDir);
|
|
109
|
+
const journal = new RunJournal({
|
|
110
|
+
// Outlive the pool by a wide margin: a journal is the only record of what
|
|
111
|
+
// the child did, and reading it must not race the idle reaper.
|
|
112
|
+
ttlMs: Math.max(10 * 60 * 1000, config.sessionIdleMinutes * 3 * 60 * 1000),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const spawn = (spec) => ctx.subprocess.spawn(spec);
|
|
116
|
+
|
|
117
|
+
// The proxy every child process is spawned with.
|
|
118
|
+
//
|
|
119
|
+
// The Cursor CLI reads `HTTPS_PROXY`/`HTTP_PROXY` and nothing else — not the
|
|
120
|
+
// Windows registry, and not `ALL_PROXY`. So a machine whose proxy lives only
|
|
121
|
+
// in Internet Settings gets a child that talks straight to the API, which
|
|
122
|
+
// answers with the small subset of models the direct route publishes. That is
|
|
123
|
+
// the entire "some models are missing" symptom, and the fix is to hand the
|
|
124
|
+
// child the variables it is looking for.
|
|
125
|
+
//
|
|
126
|
+
// Resolution is asynchronous (the registry has to be asked), so the pool is
|
|
127
|
+
// handed a *function* rather than a snapshot, and re-resolved whenever the
|
|
128
|
+
// panel saves a new value. A run started after the save already uses it.
|
|
129
|
+
let childEnv = process.env;
|
|
130
|
+
let proxyRoute = describeProxy(undefined);
|
|
131
|
+
let proxyError = '';
|
|
132
|
+
// The spawn path awaits this, so a run that starts while the first lookup is
|
|
133
|
+
// still in flight gets the proxy instead of racing it and quietly listing a
|
|
134
|
+
// fraction of the models.
|
|
135
|
+
let resolution = Promise.resolve();
|
|
136
|
+
// Until the first lookup settles the route is unknown, and the panel has to
|
|
137
|
+
// be told that rather than "direct": claiming a direct route during the one
|
|
138
|
+
// window where it is most likely to be wrong is how a fix looks broken.
|
|
139
|
+
let settled = false;
|
|
140
|
+
const resolveProxy = async () => {
|
|
141
|
+
const configured = readSettings(dataDir).proxy;
|
|
142
|
+
const explicit = configured.trim() !== '' && proxySettingIsDirect(configured) === false;
|
|
143
|
+
try {
|
|
144
|
+
const proxy = await resolveChildProxy(configured, { env: process.env });
|
|
145
|
+
childEnv = withProxyEnvironment(process.env, proxy);
|
|
146
|
+
proxyRoute = describeProxy(proxy);
|
|
147
|
+
// `resolveChildProxy` answers `undefined` both for "direct" and for
|
|
148
|
+
// "that text is not a proxy", and only one of those deserves a
|
|
149
|
+
// complaint in the panel.
|
|
150
|
+
proxyError = explicit && proxy === undefined
|
|
151
|
+
? `\`${configured}\` is not a usable proxy — a child would talk straight to the API`
|
|
152
|
+
: '';
|
|
153
|
+
settled = true;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
childEnv = process.env;
|
|
157
|
+
proxyRoute = describeProxy(undefined);
|
|
158
|
+
proxyError = error instanceof Error ? error.message : String(error);
|
|
159
|
+
logger?.warn?.(`dsh-cursor-dev: could not read the proxy setting: ${proxyError}`);
|
|
160
|
+
}
|
|
161
|
+
return proxyRoute;
|
|
162
|
+
};
|
|
163
|
+
const refreshProxy = () => {
|
|
164
|
+
settled = false;
|
|
165
|
+
resolution = resolveProxy();
|
|
166
|
+
return resolution;
|
|
167
|
+
};
|
|
168
|
+
const childEnvironment = () => resolution.then(() => childEnv);
|
|
169
|
+
const routeNow = () => (settled ? proxyRoute : '');
|
|
170
|
+
void refreshProxy().then(route => logger?.info?.(`dsh-cursor-dev: Cursor CLI egress via ${route}`));
|
|
171
|
+
|
|
172
|
+
// Child conversations: every Cursor run also becomes a real DSH child
|
|
173
|
+
// session, so the parent's header can list it and clicking it opens the
|
|
174
|
+
// transcript Cursor actually produced. `bind` learns the `agents` service
|
|
175
|
+
// from a context that declared the dependency, because reading it off this
|
|
176
|
+
// fiber answers `undefined` while the agent loop is still activating.
|
|
177
|
+
const mirrors = createMirrorHub({ logger, maxChildDepth: config.maxChildDepth });
|
|
178
|
+
const pool = resolved === undefined
|
|
179
|
+
? new UnavailablePool()
|
|
180
|
+
: new SessionPool({
|
|
181
|
+
resolved,
|
|
182
|
+
env: childEnvironment,
|
|
183
|
+
spawn,
|
|
184
|
+
maxLiveSessions: config.maxLiveSessions,
|
|
185
|
+
sessionIdleMinutes: config.sessionIdleMinutes,
|
|
186
|
+
}, (key, event) => {
|
|
187
|
+
journal.append(key, event);
|
|
188
|
+
mirrors.observe(key, event);
|
|
189
|
+
});
|
|
190
|
+
pool.start();
|
|
191
|
+
|
|
192
|
+
const rawTool = cursorCodeTool({
|
|
193
|
+
pool,
|
|
194
|
+
journal,
|
|
195
|
+
index,
|
|
196
|
+
logger,
|
|
197
|
+
mirrors,
|
|
198
|
+
maxScanFiles: config.maxScanFiles,
|
|
199
|
+
maxChildDepth: config.maxChildDepth,
|
|
200
|
+
unavailable,
|
|
201
|
+
// The panel writes the model the user picked; a tool call that names no
|
|
202
|
+
// model follows that choice, so switching models does not need a profile
|
|
203
|
+
// edit and a restart. Read per call: the file is a few hundred bytes and
|
|
204
|
+
// a cached value would silently ignore the operator's last click.
|
|
205
|
+
resolveDefaultModel: () => effectiveModel(readSettings(dataDir), config.model),
|
|
206
|
+
resolveModelSelection: requested => resolveModelSelection(readSettings(dataDir), config.model, requested),
|
|
207
|
+
resolveDefaultMode: () => readSettings(dataDir).mode,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const native = createNativeCursorBridge({ execute: rawTool.execute, mirrors, maxChildDepth: config.maxChildDepth });
|
|
211
|
+
const tool = { ...rawTool, execute: (args, exec) => native.execute(args, exec) };
|
|
212
|
+
const disposeTool = ctx.tools.register({ ...tool, name: config.toolName });
|
|
213
|
+
|
|
214
|
+
ctx.effect(() => {
|
|
215
|
+
void index.load();
|
|
216
|
+
return async () => {
|
|
217
|
+
disposeTool?.();
|
|
218
|
+
await native.dispose();
|
|
219
|
+
await mirrors.dispose();
|
|
220
|
+
pool.dispose();
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// The `agents` service is what makes a run into a navigable child
|
|
225
|
+
// conversation. It is injected rather than declared, for the same reason the
|
|
226
|
+
// HTTP surfaces are: the coding tool must keep working on a host that has no
|
|
227
|
+
// agent loop at all.
|
|
228
|
+
ctx.inject(['agents'], (agentsCtx) => {
|
|
229
|
+
mirrors.bind(agentsCtx);
|
|
230
|
+
});
|
|
231
|
+
ctx.inject(['subagents', 'agents'], (nativeCtx) => {
|
|
232
|
+
mirrors.bind(nativeCtx);
|
|
233
|
+
native.bind(nativeCtx);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// `/cursor-subagent [-m <model>] <task>` — the same child run the tool
|
|
237
|
+
// performs, dispatched straight from the command line instead of through a
|
|
238
|
+
// parent-model decision. Cursor writes the code; the parent model is woken
|
|
239
|
+
// with the diff facts and reviews them.
|
|
240
|
+
registerCursorCommand(ctx, {
|
|
241
|
+
toolName: config.toolName,
|
|
242
|
+
logger,
|
|
243
|
+
run: ({ task, model, agent, signal }) => tool.execute({
|
|
244
|
+
task,
|
|
245
|
+
model,
|
|
246
|
+
report: 'detailed',
|
|
247
|
+
}, { agent, signal }),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
mountHttpSurfaces(ctx, config, {
|
|
251
|
+
pool,
|
|
252
|
+
journal,
|
|
253
|
+
index,
|
|
254
|
+
resolved,
|
|
255
|
+
spawn,
|
|
256
|
+
dataDir,
|
|
257
|
+
logger,
|
|
258
|
+
mirrors,
|
|
259
|
+
// The status route asks the CLI whether the account is signed in, which
|
|
260
|
+
// means spawning it — same environment the ACP child gets.
|
|
261
|
+
env: childEnvironment,
|
|
262
|
+
// The settings route re-resolves this the moment a new proxy is saved,
|
|
263
|
+
// and reports the resulting route back to the panel.
|
|
264
|
+
refreshProxy,
|
|
265
|
+
proxyRoute: routeNow,
|
|
266
|
+
proxyError: () => proxyError,
|
|
267
|
+
cwd: process.cwd(),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
logger?.info?.(`dsh-cursor-dev: registered tool \`${config.toolName}\``);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Mount the status, usage and settings routes against the `webServer` service.
|
|
275
|
+
*
|
|
276
|
+
* This runs inside its own dependency scope, and that is the whole point: a
|
|
277
|
+
* service is only handed out once its providing fiber is ACTIVE, and at boot
|
|
278
|
+
* this plugin activates while the web server's asynchronous listen is still
|
|
279
|
+
* pending. Reading `ctx.get('webServer')` from *this* fiber therefore answered
|
|
280
|
+
* `undefined`, every `register*Route` quietly skipped its work, and the plugin
|
|
281
|
+
* shipped a working tool with no reachable HTTP surface at all — the panel
|
|
282
|
+
* rendered its own "HTTP 404" card while `cursor_code` kept working.
|
|
283
|
+
* Declaring the dependency keeps the mount pending until the service exists,
|
|
284
|
+
* whatever the host's plugin order turns out to be.
|
|
285
|
+
*
|
|
286
|
+
* @param ctx - Cordis context of the plugin.
|
|
287
|
+
* @param config - resolved plugin config.
|
|
288
|
+
* @param deps - the plugin's own objects, passed through to the route modules.
|
|
289
|
+
*/
|
|
290
|
+
function mountHttpSurfaces(ctx, config, deps) {
|
|
291
|
+
const mount = (webCtx) => {
|
|
292
|
+
webCtx.effect(() => {
|
|
293
|
+
// Every route below lives outside the host's `/api` carrier, so the
|
|
294
|
+
// host's own Host/Origin fence never runs for them. This is that
|
|
295
|
+
// fence: it refuses a browser request that came from another origin
|
|
296
|
+
// (a CSRF write against `/api/settings`, a forged `/api/restore`)
|
|
297
|
+
// while leaving a local tool's request and the panel's own
|
|
298
|
+
// same-origin fetches exactly as they were.
|
|
299
|
+
const fence = createRouteFence(ctx);
|
|
300
|
+
const fenced = { ...deps, fence };
|
|
301
|
+
const disposers = [registerStatusRoute(webCtx, fenced)];
|
|
302
|
+
|
|
303
|
+
// Cursor never exposes quota as a CLI command, so the plugin reads
|
|
304
|
+
// the dashboard RPC itself and serves both a JSON view and a
|
|
305
|
+
// self-contained HTML dashboard.
|
|
306
|
+
if (config.usageUi === true) {
|
|
307
|
+
const usage = new UsageClient({ baseUrl: config.usageBaseUrl });
|
|
308
|
+
disposers.push(registerUsageRoutes(webCtx, {
|
|
309
|
+
usage,
|
|
310
|
+
pool: deps.pool,
|
|
311
|
+
journal: deps.journal,
|
|
312
|
+
index: deps.index,
|
|
313
|
+
dataDir: deps.dataDir,
|
|
314
|
+
configModel: config.model,
|
|
315
|
+
fence,
|
|
316
|
+
// The dashboard offers a jump into the mirrored child
|
|
317
|
+
// conversation, so it has to know both whether this host has
|
|
318
|
+
// one and which child belongs to which run.
|
|
319
|
+
mirrors: deps.mirrors,
|
|
320
|
+
// …and it shows the egress route, because that is what
|
|
321
|
+
// decides how many models the picker can offer.
|
|
322
|
+
proxyRoute: deps.proxyRoute,
|
|
323
|
+
proxyError: deps.proxyError,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Settings the panel owns: the child's default model, the language.
|
|
328
|
+
disposers.push(registerSettingsRoutes(webCtx, {
|
|
329
|
+
spawn: deps.spawn,
|
|
330
|
+
resolved: deps.resolved,
|
|
331
|
+
env: deps.env,
|
|
332
|
+
cwd: deps.cwd,
|
|
333
|
+
dataDir: deps.dataDir,
|
|
334
|
+
configModel: config.model,
|
|
335
|
+
logger: deps.logger,
|
|
336
|
+
fence,
|
|
337
|
+
// The panel's model list must be the ids `set_config_option`
|
|
338
|
+
// accepts, and only the ACP session roster publishes those. The
|
|
339
|
+
// pool reads one from a throwaway session; a pool that cannot
|
|
340
|
+
// (no CLI) leaves the probe absent and the CLI listing answers.
|
|
341
|
+
probeModelRoster: typeof deps.pool.probeModelRoster === 'function'
|
|
342
|
+
? () => deps.pool.probeModelRoster()
|
|
343
|
+
: undefined,
|
|
344
|
+
// Saving a proxy has to take effect on the next spawn, not on
|
|
345
|
+
// the next restart, and the panel shows the route that results.
|
|
346
|
+
refreshProxy: deps.refreshProxy,
|
|
347
|
+
proxyRoute: deps.proxyRoute,
|
|
348
|
+
proxyError: deps.proxyError,
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
deps.logger?.info?.(`dsh-cursor-dev: serving ${disposers.length} HTTP surface(s)`);
|
|
352
|
+
|
|
353
|
+
return () => {
|
|
354
|
+
for (const dispose of disposers) {
|
|
355
|
+
try {
|
|
356
|
+
dispose?.();
|
|
357
|
+
} catch (error) {
|
|
358
|
+
deps.logger?.warn?.('dsh-cursor-dev: releasing an HTTP surface failed', error);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
if (typeof ctx.inject !== 'function') {
|
|
366
|
+
// A host without the registry mixin cannot defer anything; mount
|
|
367
|
+
// eagerly and let `ctx.get` decide, exactly as this plugin used to.
|
|
368
|
+
deps.logger?.warn?.('dsh-cursor-dev: ctx.inject is unavailable; mounting the HTTP surfaces eagerly');
|
|
369
|
+
mount(ctx);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
ctx.inject(['webServer'], mount);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Stand-in for the session pool when no Cursor CLI could be found.
|
|
377
|
+
*
|
|
378
|
+
* Everything except running the child keeps working through it: the usage
|
|
379
|
+
* dashboard and the status route still answer, and `cursor_code` reports the
|
|
380
|
+
* resolution problem. A missing CLI must not fail the fiber, because that hides
|
|
381
|
+
* every other surface along with the reason.
|
|
382
|
+
*/
|
|
383
|
+
class UnavailablePool {
|
|
384
|
+
/** No run can be queued. */
|
|
385
|
+
get queued() {
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** No live sessions exist. @returns {never[]} */
|
|
390
|
+
snapshot() {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
start() {
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async dispose() {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Expose the live pool over HTTP so the user can inspect a run without asking
|
|
403
|
+
* the model. The route is read-only and never mutates a session.
|
|
404
|
+
*
|
|
405
|
+
* `WebRoute` is a Node-style carrier: `{ kind: 'exact' | 'prefix', path, handler(req, res) }`.
|
|
406
|
+
*
|
|
407
|
+
* @param ctx - Cordis context.
|
|
408
|
+
* @param deps - pool, journal, index, resolved agent.
|
|
409
|
+
*/
|
|
410
|
+
function registerStatusRoute(ctx, deps) {
|
|
411
|
+
const server = ctx.get('webServer');
|
|
412
|
+
if (server === undefined)
|
|
413
|
+
return undefined;
|
|
414
|
+
try {
|
|
415
|
+
return server.register({
|
|
416
|
+
kind: 'exact',
|
|
417
|
+
path: '/plugins/dsh-cursor-dev/status',
|
|
418
|
+
handler: async (req, res) => {
|
|
419
|
+
// Same cross-site fence as the other plugin routes: this one is
|
|
420
|
+
// readable, but it names the workspace and the live session ids,
|
|
421
|
+
// so a foreign page has no business reading it either.
|
|
422
|
+
if (deps.fence !== undefined && deps.fence(req, res) !== true)
|
|
423
|
+
return undefined;
|
|
424
|
+
// Nothing here may throw: an escaping exception is an unhandled
|
|
425
|
+
// error in the host's server, not a failed response.
|
|
426
|
+
let body;
|
|
427
|
+
try {
|
|
428
|
+
const url = new URL(req?.url ?? '/', 'http://localhost');
|
|
429
|
+
body = {
|
|
430
|
+
// `found` is what the panel branches on: it turns a panel
|
|
431
|
+
// full of dashes into the two commands that fix it.
|
|
432
|
+
agent: describeAgent(deps.resolved),
|
|
433
|
+
auth: await authStatus({
|
|
434
|
+
...deps,
|
|
435
|
+
// The panel's retry button asks for a fresh answer, so
|
|
436
|
+
// signing in and clicking it does not meet the cache.
|
|
437
|
+
force: url.searchParams.get('auth') === 'refresh',
|
|
438
|
+
}),
|
|
439
|
+
setup: CLI_SETUP,
|
|
440
|
+
live: deps.pool.snapshot(),
|
|
441
|
+
// The child conversations the parent's header lists.
|
|
442
|
+
// `available: false` means this host has no agent loop,
|
|
443
|
+
// so a run is visible in the tool result only.
|
|
444
|
+
children: {
|
|
445
|
+
available: deps.mirrors?.available === true,
|
|
446
|
+
open: deps.mirrors?.snapshot?.() ?? [],
|
|
447
|
+
},
|
|
448
|
+
conversations: deps.index.list().map(entry => ({
|
|
449
|
+
key: entry.key,
|
|
450
|
+
cursorSessionId: entry.cursorSessionId,
|
|
451
|
+
childSessionId: entry.childSessionId,
|
|
452
|
+
workspaceRoot: entry.workspaceRoot,
|
|
453
|
+
model: entry.model,
|
|
454
|
+
updatedAt: entry.updatedAt,
|
|
455
|
+
})),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
460
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const payload = JSON.stringify(body, null, 2);
|
|
464
|
+
res.writeHead(200, {
|
|
465
|
+
'content-type': 'application/json; charset=utf-8',
|
|
466
|
+
'cache-control': 'no-store',
|
|
467
|
+
});
|
|
468
|
+
res.end(payload);
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
// A status route is a convenience; never let it block the coding tool.
|
|
474
|
+
ctx.logger?.warn?.(`dsh-cursor-dev: status route unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
}
|