dsh-cursor-dev 0.0.0-stage → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }