dsh-cursor-subscription 0.6.3 → 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.
Files changed (2) hide show
  1. package/lib/index.js +176 -17
  2. package/package.json +1 -1
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";
@@ -173,6 +175,12 @@ export const STREAM_PROGRESS_TIMEOUT_MS = 60 * 1000;
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
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
- // Cursor emits this checkpoint after mcpArgs. Keep the same Run
2621
- // alive; the next DSH step resumes it with McpResult field 11.
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
  }
@@ -2748,10 +2900,16 @@ export class CursorAdapter extends LlmAdapter {
2748
2900
  };
2749
2901
  pendingExecs.push({ id: exec.id, execId: exec.execId, toolCallId: id });
2750
2902
  toolCallPending = true;
2751
- // Do not close the Run. Cursor sends blob writes + a checkpoint
2752
- // immediately after mcpArgs; once captured, return tool-calls to DSH
2753
- // while preserving this bridge for the result.
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.
2754
2911
  streamClosed = true;
2912
+ armToolCallSettle();
2755
2913
  } else {
2756
2914
  const reply = rejectionFor(exec);
2757
2915
  if (reply !== undefined) {
@@ -2762,6 +2920,7 @@ export class CursorAdapter extends LlmAdapter {
2762
2920
  }
2763
2921
  } finally {
2764
2922
  clearInterval(idleCheck);
2923
+ clearTimeout(toolCallSettle);
2765
2924
  }
2766
2925
 
2767
2926
  if (!terminalErrorEmitted && !toolCallPending) {
@@ -2960,7 +3119,7 @@ export function apply(ctx, config = {}) {
2960
3119
  };
2961
3120
  const store = new CursorCredentialStore(ctx.credentials, CREDENTIAL_REF);
2962
3121
  const auth = new CursorAuthService(store, { logger: ctx.logger });
2963
- 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 });
2964
3123
  ctx.llm.registerAdapter([PROVIDER], adapter);
2965
3124
  const usageReader = new CursorUsageReader(auth, { logger: ctx.logger });
2966
3125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cursor-subscription",
3
- "version": "0.6.3",
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",