dsh-cursor-subscription 0.6.2 → 0.6.5
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/README.md +1 -1
- package/README_zh.md +2 -1
- package/lib/index.js +189 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ Then add `dsh-cursor-subscription` to the `dsh.profile.bundles` array in `packag
|
|
|
60
60
|
|
|
61
61
|
When a tool is required, the agent converts Cursor's tool request into a local DSH tool execution. The result is returned to the model through the conversation history; Cursor's filesystem tools are not used.
|
|
62
62
|
|
|
63
|
-
The **Runtime settings** card controls the maximum tool rounds in one Cursor run and the HTTP retry policy. Retry count means additional attempts and defaults to `0` (disabled). Cursor's streaming POST protocol cannot prove that a failed attempt was not processed remotely, so enabling retries may repeat model work or usage. Retries occur only before any response output when the initial HTTP status matches the configured list.
|
|
63
|
+
The **Runtime settings** card controls the maximum tool rounds in one Cursor run and the HTTP retry policy. The tool-round cap defaults to `200` (range 1–1000) and ends the run with `TOOL_LIMIT` when exceeded. Retry count means additional attempts and defaults to `0` (disabled). Cursor's streaming POST protocol cannot prove that a failed attempt was not processed remotely, so enabling retries may repeat model work or usage. Retries occur only before any response output when the initial HTTP status matches the configured list.
|
|
64
64
|
|
|
65
65
|
## Updating and Removing
|
|
66
66
|
|
package/README_zh.md
CHANGED
|
@@ -71,7 +71,8 @@ pnpm add file:D:/mcp/dsh-cursor-subscription
|
|
|
71
71
|
需要调用工具时,Agent 会把 Cursor 侧的工具请求转成 DSH 本地工具执行,结果通过
|
|
72
72
|
对话历史回传给模型,全程不经过 Cursor 的文件系统工具。
|
|
73
73
|
|
|
74
|
-
「运行设置」卡片可以调整单次 Cursor 任务的最大工具轮次和 HTTP
|
|
74
|
+
「运行设置」卡片可以调整单次 Cursor 任务的最大工具轮次和 HTTP 重试策略。工具轮次上限
|
|
75
|
+
默认 `200`(范围 1–1000),超过后以 `TOOL_LIMIT` 终止该次运行。重试次数指
|
|
75
76
|
首次请求之外的额外尝试次数,默认 `0`(关闭)。Cursor 的流式 POST 协议无法证明失败请求
|
|
76
77
|
未被远端处理,因此启用重试可能重复模型工作或用量;仅当尚未产生任何输出且初始 HTTP
|
|
77
78
|
状态码匹配列表时才会重试。
|
package/lib/index.js
CHANGED
|
@@ -17,7 +17,9 @@ import z from "@deepseek-ai/schemastery";
|
|
|
17
17
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
18
18
|
import { ToolCallId, LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
|
|
19
19
|
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
-
import { readFileSync } from "node:fs";
|
|
20
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
21
23
|
import http2 from "node:http2";
|
|
22
24
|
import http from "node:http";
|
|
23
25
|
import https from "node:https";
|
|
@@ -172,7 +174,13 @@ export const STREAM_PROGRESS_TIMEOUT_MS = 60 * 1000;
|
|
|
172
174
|
/** Retain checkpoints/live tool bridges for inactive DSH sessions. */
|
|
173
175
|
export const SESSION_STATE_TTL_MS = 30 * 60 * 1000;
|
|
174
176
|
/** Stop one Cursor Run before an unconstrained agent can loop forever. */
|
|
175
|
-
export const MAX_TOOL_ROUNDS =
|
|
177
|
+
export const MAX_TOOL_ROUNDS = 200;
|
|
178
|
+
/**
|
|
179
|
+
* How long a tool-call step keeps reading after `mcpArgs` for the frames that
|
|
180
|
+
* close the burst (sibling calls, blob writes, checkpoint). Sibling calls of
|
|
181
|
+
* one burst arrive within ~100 ms of each other.
|
|
182
|
+
*/
|
|
183
|
+
export const TOOL_CALL_SETTLE_MS = 500;
|
|
176
184
|
export const DEFAULT_RETRY_COUNT = 0;
|
|
177
185
|
export const DEFAULT_RETRY_INTERVAL_MS = 1000;
|
|
178
186
|
export const DEFAULT_RETRY_HTTP_STATUS_CODES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
|
|
@@ -1156,6 +1164,62 @@ export function decodeAgentServerMessage(bytes) {
|
|
|
1156
1164
|
return { case: "unknown", value: undefined };
|
|
1157
1165
|
}
|
|
1158
1166
|
|
|
1167
|
+
/** Field numbers inside one protobuf message; used to describe frames we do not decode. */
|
|
1168
|
+
function messageFields(bytes) {
|
|
1169
|
+
const reader = new Reader(bytes);
|
|
1170
|
+
const fields = [];
|
|
1171
|
+
while (!reader.done) {
|
|
1172
|
+
const { field, wireType } = reader.tag();
|
|
1173
|
+
fields.push(field);
|
|
1174
|
+
reader.skip(wireType);
|
|
1175
|
+
}
|
|
1176
|
+
return fields.join(",");
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** Field numbers of a nested message (`wanted` inside `bytes`). */
|
|
1180
|
+
function nestedMessageFields(bytes, wanted) {
|
|
1181
|
+
const reader = new Reader(bytes);
|
|
1182
|
+
while (!reader.done) {
|
|
1183
|
+
const { field, wireType } = reader.tag();
|
|
1184
|
+
if (wireType !== 2) {
|
|
1185
|
+
reader.skip(wireType);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const payload = reader.bytes();
|
|
1189
|
+
if (field === wanted) return messageFields(payload);
|
|
1190
|
+
}
|
|
1191
|
+
return "";
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* One-line description of a server frame for the stall trace. Undecoded
|
|
1196
|
+
* frames report their protobuf field numbers so a server message the adapter
|
|
1197
|
+
* ignores is still visible when a run goes silent.
|
|
1198
|
+
*/
|
|
1199
|
+
export function describeServerFrame(frame) {
|
|
1200
|
+
if ((frame.flags & CONNECT_END_STREAM_FLAG) !== 0) {
|
|
1201
|
+
return `endstream(${Buffer.from(frame.payload).toString("utf8").replace(/\s+/g, " ").slice(0, 60)})`;
|
|
1202
|
+
}
|
|
1203
|
+
try {
|
|
1204
|
+
const message = decodeAgentServerMessage(frame.payload);
|
|
1205
|
+
if (message.case === "interactionUpdate") {
|
|
1206
|
+
const update = message.value;
|
|
1207
|
+
if (update.type === "textDelta") return `text(${update.text.length})`;
|
|
1208
|
+
if (update.type === "thinkingDelta") return `thinking(${update.text.length})`;
|
|
1209
|
+
if (update.type === "tokenDelta") return `tokens(${update.tokens})`;
|
|
1210
|
+
if (update.type === "partialToolCall") return "partialToolCall";
|
|
1211
|
+
if (update.type === "unknown") return `interaction#${nestedMessageFields(frame.payload, 1)}`;
|
|
1212
|
+
return update.type;
|
|
1213
|
+
}
|
|
1214
|
+
if (message.case === "conversationCheckpointUpdate") return `checkpoint(${message.value.length}B)`;
|
|
1215
|
+
if (message.case === "kvServerMessage") return `kv:${message.value.case}`;
|
|
1216
|
+
if (message.case === "execServerMessage") return `exec:${message.value.case}`;
|
|
1217
|
+
return `message#${messageFields(frame.payload)}`;
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
return `undecodable(${(error instanceof Error ? error.message : String(error)).slice(0, 40)})`;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1159
1223
|
/** InteractionUpdate { text_delta=1, thinking_delta=4, token_delta=8, turn_ended=14, ... } */
|
|
1160
1224
|
export function decodeInteractionUpdate(bytes) {
|
|
1161
1225
|
const reader = new Reader(bytes);
|
|
@@ -1410,6 +1474,7 @@ export class ConnectFrameReader {
|
|
|
1410
1474
|
this.frames = [];
|
|
1411
1475
|
this.waiters = [];
|
|
1412
1476
|
this.ended = false;
|
|
1477
|
+
this.paused = false;
|
|
1413
1478
|
this.error = undefined;
|
|
1414
1479
|
}
|
|
1415
1480
|
|
|
@@ -1452,6 +1517,21 @@ export class ConnectFrameReader {
|
|
|
1452
1517
|
for (const waiter of this.waiters.splice(0)) waiter.resolve(undefined);
|
|
1453
1518
|
}
|
|
1454
1519
|
|
|
1520
|
+
/**
|
|
1521
|
+
* Stop blocking for new frames without ending the stream: buffered frames
|
|
1522
|
+
* are still returned, and `resume()` restores normal waiting. A Cursor run
|
|
1523
|
+
* stays alive across DSH steps, so a reader that is paused to end one step
|
|
1524
|
+
* must accept frames again when the next step continues the same run.
|
|
1525
|
+
*/
|
|
1526
|
+
pause() {
|
|
1527
|
+
this.paused = true;
|
|
1528
|
+
for (const waiter of this.waiters.splice(0)) waiter.resolve(undefined);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
resume() {
|
|
1532
|
+
this.paused = false;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1455
1535
|
fail(error) {
|
|
1456
1536
|
this.error = error;
|
|
1457
1537
|
this.ended = true;
|
|
@@ -1460,6 +1540,7 @@ export class ConnectFrameReader {
|
|
|
1460
1540
|
|
|
1461
1541
|
async next() {
|
|
1462
1542
|
if (this.frames.length > 0) return this.frames.shift();
|
|
1543
|
+
if (this.paused) return undefined;
|
|
1463
1544
|
if (this.ended) {
|
|
1464
1545
|
if (this.error !== undefined) throw this.error;
|
|
1465
1546
|
return undefined;
|
|
@@ -2398,6 +2479,9 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2398
2479
|
this.resolveAttachments = options.resolveAttachments;
|
|
2399
2480
|
this.sleep = options.sleep ?? abortableDelay;
|
|
2400
2481
|
this.now = options.now ?? Date.now;
|
|
2482
|
+
this.logger = options.logger;
|
|
2483
|
+
this.hangTracePath = options.hangTracePath ?? join(homedir(), ".dsh", "cursor-hang-trace.log");
|
|
2484
|
+
this.toolCallSettleMs = options.toolCallSettleMs ?? TOOL_CALL_SETTLE_MS;
|
|
2401
2485
|
this.#modelsCache = { at: 0, models: undefined };
|
|
2402
2486
|
}
|
|
2403
2487
|
|
|
@@ -2501,6 +2585,9 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2501
2585
|
let blobStore;
|
|
2502
2586
|
if (canResume) {
|
|
2503
2587
|
run = liveBridge.run;
|
|
2588
|
+
// A previous step paused this reader to stop waiting for frames it
|
|
2589
|
+
// no longer needed; continue the same Run and read again.
|
|
2590
|
+
run.frames?.resume?.();
|
|
2504
2591
|
blobStore = persisted.blobs;
|
|
2505
2592
|
abortRun = () => run.abort(new Error("Cursor request aborted by caller"));
|
|
2506
2593
|
upstream.addEventListener("abort", abortRun, { once: true });
|
|
@@ -2560,16 +2647,6 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2560
2647
|
const started = this.now();
|
|
2561
2648
|
let lastActivity = started;
|
|
2562
2649
|
let lastProgress = started;
|
|
2563
|
-
const idleCheck = setInterval(() => {
|
|
2564
|
-
const now = this.now();
|
|
2565
|
-
if (now - lastActivity > this.idleTimeoutMs) {
|
|
2566
|
-
run.abort(new Error("Cursor stream idle timeout"));
|
|
2567
|
-
} else if (now - lastProgress > this.progressTimeoutMs) {
|
|
2568
|
-
run.abort(new Error(`Cursor stream progress timeout: no content for ${this.progressTimeoutMs}ms`));
|
|
2569
|
-
}
|
|
2570
|
-
}, this.idleCheckIntervalMs);
|
|
2571
|
-
idleCheck.unref?.();
|
|
2572
|
-
|
|
2573
2650
|
let emittedToolCall = false;
|
|
2574
2651
|
let toolCallBuffer = undefined;
|
|
2575
2652
|
let textOutput = "";
|
|
@@ -2579,12 +2656,84 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2579
2656
|
let toolCallPending = false;
|
|
2580
2657
|
let toolRoundCounted = false;
|
|
2581
2658
|
const pendingExecs = [];
|
|
2659
|
+
let toolCallSettle;
|
|
2660
|
+
/**
|
|
2661
|
+
* Stop blocking for more frames without ending the reader, so the same
|
|
2662
|
+
* Run can be continued by the next DSH step.
|
|
2663
|
+
*/
|
|
2664
|
+
const stopReading = () => {
|
|
2665
|
+
if (typeof run.frames?.pause === "function") run.frames.pause();
|
|
2666
|
+
else if (typeof run.frames?.finish === "function") run.frames.finish();
|
|
2667
|
+
};
|
|
2668
|
+
/**
|
|
2669
|
+
* Cap how long the loop waits for the frame that closes a tool-call
|
|
2670
|
+
* burst. Cursor usually follows mcpArgs with blob writes and a
|
|
2671
|
+
* checkpoint, but it may instead send the checkpoint *before* the burst
|
|
2672
|
+
* and then nothing at all; waiting for a second checkpoint in that case
|
|
2673
|
+
* hung the step until the progress watchdog aborted it.
|
|
2674
|
+
*/
|
|
2675
|
+
const armToolCallSettle = () => {
|
|
2676
|
+
toolCallSettle ??= setTimeout(stopReading, this.toolCallSettleMs);
|
|
2677
|
+
toolCallSettle.unref?.();
|
|
2678
|
+
};
|
|
2679
|
+
// Compact trace of the frames this run received, kept for the stall
|
|
2680
|
+
// report so a silent server can be diagnosed from the log alone.
|
|
2681
|
+
const frameTrace = [];
|
|
2682
|
+
const traceFrame = (label) => {
|
|
2683
|
+
const last = frameTrace.at(-1);
|
|
2684
|
+
if (label === "heartbeat" && last?.label === "heartbeat") {
|
|
2685
|
+
last.count++;
|
|
2686
|
+
last.at = this.now();
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
frameTrace.push({ label, at: this.now(), count: 1 });
|
|
2690
|
+
if (frameTrace.length > 120) frameTrace.shift();
|
|
2691
|
+
};
|
|
2692
|
+
const stallReport = (message) => {
|
|
2693
|
+
const lines = [
|
|
2694
|
+
`=== ${new Date().toISOString()} ${message} ===`,
|
|
2695
|
+
`session=${sessionKey ?? "-"} model=${options.model ?? "-"} textChars=${textOutput.length}`
|
|
2696
|
+
+ ` toolCallPending=${toolCallPending} toolRounds=${toolRoundCount}`
|
|
2697
|
+
+ ` checkpoint=${persisted.checkpoint?.length ?? 0}B blobs=${blobStore?.size ?? 0}`,
|
|
2698
|
+
`lastFrame=${Math.round((this.now() - lastActivity) / 1000)}s ago`
|
|
2699
|
+
+ ` lastContent=${Math.round((this.now() - lastProgress) / 1000)}s ago`,
|
|
2700
|
+
...frameTrace.map((entry) => ` ${new Date(entry.at).toISOString()} ${entry.label}${entry.count > 1 ? ` x${entry.count}` : ""}`),
|
|
2701
|
+
"",
|
|
2702
|
+
];
|
|
2703
|
+
try {
|
|
2704
|
+
appendFileSync(this.hangTracePath, lines.join("\n"));
|
|
2705
|
+
} catch {}
|
|
2706
|
+
this.logger?.warn?.(`cursor-subscription: ${message}; trace in ${this.hangTracePath}`);
|
|
2707
|
+
};
|
|
2708
|
+
const handleStall = (message) => {
|
|
2709
|
+
stallReport(message);
|
|
2710
|
+
// The step already delivered something the user can see — answer
|
|
2711
|
+
// text or a tool call — and Cursor stopped sending content without
|
|
2712
|
+
// ending the turn. Stop reading instead of raising a retryable
|
|
2713
|
+
// error: DSH would discard the delivered answer or tool call and
|
|
2714
|
+
// re-run the whole step.
|
|
2715
|
+
if ((textOutput.length > 0 || toolCallPending) && typeof run.frames?.pause === "function") {
|
|
2716
|
+
stopReading();
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
run.abort(new Error(message));
|
|
2720
|
+
};
|
|
2721
|
+
const idleCheck = setInterval(() => {
|
|
2722
|
+
const now = this.now();
|
|
2723
|
+
if (now - lastActivity > this.idleTimeoutMs) {
|
|
2724
|
+
handleStall("Cursor stream idle timeout");
|
|
2725
|
+
} else if (now - lastProgress > this.progressTimeoutMs) {
|
|
2726
|
+
handleStall(`Cursor stream progress timeout: no content for ${this.progressTimeoutMs}ms`);
|
|
2727
|
+
}
|
|
2728
|
+
}, this.idleCheckIntervalMs);
|
|
2729
|
+
idleCheck.unref?.();
|
|
2582
2730
|
|
|
2583
2731
|
try {
|
|
2584
2732
|
for (;;) {
|
|
2585
2733
|
const frame = await run.frames.next();
|
|
2586
2734
|
if (frame === undefined) break;
|
|
2587
2735
|
lastActivity = this.now();
|
|
2736
|
+
traceFrame(describeServerFrame(frame));
|
|
2588
2737
|
if ((frame.flags & CONNECT_END_STREAM_FLAG) !== 0) {
|
|
2589
2738
|
const end = parseEndStream(frame.payload);
|
|
2590
2739
|
if (end !== undefined) {
|
|
@@ -2617,8 +2766,11 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2617
2766
|
// retaining it for a later request.
|
|
2618
2767
|
persisted.checkpoint = Uint8Array.from(message.value);
|
|
2619
2768
|
if (toolCallPending) {
|
|
2620
|
-
//
|
|
2621
|
-
// alive; the next DSH step resumes it with McpResult
|
|
2769
|
+
// A checkpoint after mcpArgs closes the tool-call step. Keep the
|
|
2770
|
+
// same Run alive; the next DSH step resumes it with McpResult
|
|
2771
|
+
// field 11. Cursor does not always send this checkpoint after
|
|
2772
|
+
// the burst — the traced hangs have it *before* mcpArgs — so the
|
|
2773
|
+
// wait for it is capped by the settle timer armed below.
|
|
2622
2774
|
streamClosed = true;
|
|
2623
2775
|
break;
|
|
2624
2776
|
}
|
|
@@ -2646,6 +2798,18 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2646
2798
|
}
|
|
2647
2799
|
} else if (update.type === "toolCallStarted" || update.type === "toolCallCompleted" || update.type === "toolCallDelta") {
|
|
2648
2800
|
emittedToolCall = true;
|
|
2801
|
+
} else if (update.type === "turnEnded") {
|
|
2802
|
+
// Cursor ends every assistant turn with this update, but it does
|
|
2803
|
+
// not reliably close the HTTP/2 stream afterwards: the server can
|
|
2804
|
+
// keep the run alive with heartbeats only. Ignoring the update
|
|
2805
|
+
// therefore left a finished answer waiting until the progress
|
|
2806
|
+
// watchdog aborted the run, and the resulting retryable TIMEOUT
|
|
2807
|
+
// made DSH discard the answer the user had already read and
|
|
2808
|
+
// re-run the whole step. The trailing conversation checkpoint
|
|
2809
|
+
// always arrives before turnEnded, so stopping here keeps
|
|
2810
|
+
// everything a later run needs. A pending MCP tool call still
|
|
2811
|
+
// waits for its checkpoint so the live bridge stays resumable.
|
|
2812
|
+
if (!toolCallPending) break;
|
|
2649
2813
|
}
|
|
2650
2814
|
} else if (message.case === "kvServerMessage") {
|
|
2651
2815
|
const kv = message.value;
|
|
@@ -2736,10 +2900,16 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2736
2900
|
};
|
|
2737
2901
|
pendingExecs.push({ id: exec.id, execId: exec.execId, toolCallId: id });
|
|
2738
2902
|
toolCallPending = true;
|
|
2739
|
-
// Do not close the Run. Cursor sends blob writes + a
|
|
2740
|
-
//
|
|
2741
|
-
//
|
|
2903
|
+
// Do not close the Run. Cursor usually sends blob writes + a
|
|
2904
|
+
// checkpoint right after mcpArgs, which this loop still wants to
|
|
2905
|
+
// capture for the persisted session, but the ordering is not
|
|
2906
|
+
// guaranteed: traced hangs end with `checkpoint | mcpArgs` and no
|
|
2907
|
+
// further frame, which left the step waiting until the progress
|
|
2908
|
+
// watchdog aborted it and DSH re-ran the whole step instead of
|
|
2909
|
+
// running the tool. Cap the wait; sibling calls of one burst
|
|
2910
|
+
// arrive within ~100 ms of each other.
|
|
2742
2911
|
streamClosed = true;
|
|
2912
|
+
armToolCallSettle();
|
|
2743
2913
|
} else {
|
|
2744
2914
|
const reply = rejectionFor(exec);
|
|
2745
2915
|
if (reply !== undefined) {
|
|
@@ -2750,6 +2920,7 @@ export class CursorAdapter extends LlmAdapter {
|
|
|
2750
2920
|
}
|
|
2751
2921
|
} finally {
|
|
2752
2922
|
clearInterval(idleCheck);
|
|
2923
|
+
clearTimeout(toolCallSettle);
|
|
2753
2924
|
}
|
|
2754
2925
|
|
|
2755
2926
|
if (!terminalErrorEmitted && !toolCallPending) {
|
|
@@ -2948,7 +3119,7 @@ export function apply(ctx, config = {}) {
|
|
|
2948
3119
|
};
|
|
2949
3120
|
const store = new CursorCredentialStore(ctx.credentials, CREDENTIAL_REF);
|
|
2950
3121
|
const auth = new CursorAuthService(store, { logger: ctx.logger });
|
|
2951
|
-
const adapter = new CursorAdapter({ auth, settings: readSettings, resolveAttachments: () => ctx.get("attachments") });
|
|
3122
|
+
const adapter = new CursorAdapter({ auth, settings: readSettings, resolveAttachments: () => ctx.get("attachments"), logger: ctx.logger });
|
|
2952
3123
|
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
2953
3124
|
const usageReader = new CursorUsageReader(auth, { logger: ctx.logger });
|
|
2954
3125
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cursor-subscription",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"packageManager": "pnpm@11.19.0",
|
|
5
5
|
"description": "Cursor subscription for DeepSeek Harness with browser login, token refresh, model discovery, and the Cursor Agent chat protocol",
|
|
6
6
|
"type": "module",
|