cursor-opencode-provider 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
@@ -1,11 +1,10 @@
1
1
  import type { LanguageModelV3CallOptions } from "@ai-sdk/provider";
2
2
  /** Mirrors MAX_TURN_STATE_SESSIONS; evicted sessions re-hydrate from disk. */
3
3
  export declare const MAX_PROVENANCE_SESSIONS = 256;
4
- export type TurnProvenance = {
5
- conversationId: string;
6
- /** Tool call ids emitted in the latest non-empty step. */
4
+ type EmittedStep = {
5
+ /** Tool call ids emitted in the step. */
7
6
  toolCallIds: string[];
8
- /** Whitespace-free text emitted in the latest non-empty step (first 4 KiB). */
7
+ /** Whitespace-free text emitted in the step (first 4 KiB). */
9
8
  text: string;
10
9
  /**
11
10
  * Whitespace-free reasoning and text of the same step in emission order
@@ -15,6 +14,17 @@ export type TurnProvenance = {
15
14
  */
16
15
  textWithReasoning: string;
17
16
  };
17
+ /** The latest non-empty step, as fields of the record itself. */
18
+ export type TurnProvenance = EmittedStep & {
19
+ conversationId: string;
20
+ /**
21
+ * The latest step has not finished: it is still streaming, or the host
22
+ * stopped it. The host may keep only what it read of it, or drop it.
23
+ */
24
+ unfinished?: boolean;
25
+ /** The step before an unfinished one, which the host's history may still end on. */
26
+ previous?: EmittedStep;
27
+ };
18
28
  export type ForeignHistoryReason = "foreign-assistant";
19
29
  /**
20
30
  * Mark the start of one host step (one doStream). The previous step stays
@@ -28,6 +38,10 @@ export declare function recordEmittedPart(sessionKey: string, conversationId: st
28
38
  delta?: unknown;
29
39
  toolCallId?: unknown;
30
40
  }): void;
41
+ /** The host step's stream finished normally; an empty step leaves the previous one recorded. */
42
+ export declare function finishEmittedStep(sessionKey: string, conversationId: string): void;
43
+ /** The finished step goes on in the same host step (a progress-only continuation Run). */
44
+ export declare function resumeEmittedStep(sessionKey: string, conversationId: string): void;
31
45
  /**
32
46
  * Bind provenance to the conversation a Run was opened on. A reminted
33
47
  * conversation starts with an empty record, so it never inherits the previous
@@ -50,3 +64,4 @@ export declare function detectForeignHistory(input: {
50
64
  conversationId: string;
51
65
  prompt: LanguageModelV3CallOptions["prompt"];
52
66
  }): ForeignHistoryReason | undefined;
67
+ export {};
@@ -37,10 +37,18 @@ function entryFor(sessionKey, conversationId) {
37
37
  function appendCapped(value, delta) {
38
38
  return value.length < MAX_TEXT_CHARS ? (value + delta).slice(0, MAX_TEXT_CHARS) : value;
39
39
  }
40
+ function hasContent(step) {
41
+ return step.toolCallIds.length > 0 || !!step.text || !!step.textWithReasoning;
42
+ }
43
+ function copyStep(step) {
44
+ return { toolCallIds: [...step.toolCallIds], text: step.text, textWithReasoning: step.textWithReasoning };
45
+ }
40
46
  function contentEntry(sessionKey, conversationId) {
41
47
  const entry = entryFor(sessionKey, conversationId);
42
48
  if (entry.stepPending) {
49
+ entry.previous = hasContent(entry) ? copyStep(entry) : undefined;
43
50
  entry.stepPending = false;
51
+ entry.unfinished = true;
44
52
  entry.toolCallIds = [];
45
53
  entry.text = "";
46
54
  entry.textWithReasoning = "";
@@ -73,6 +81,21 @@ export function recordEmittedPart(sessionKey, conversationId, part) {
73
81
  entry.toolCallIds.push(part.toolCallId);
74
82
  }
75
83
  }
84
+ /** The host step's stream finished normally; an empty step leaves the previous one recorded. */
85
+ export function finishEmittedStep(sessionKey, conversationId) {
86
+ const entry = provenanceBySession.get(sessionKey);
87
+ if (!entry || entry.conversationId !== conversationId || entry.stepPending)
88
+ return;
89
+ entry.unfinished = false;
90
+ entry.previous = undefined;
91
+ }
92
+ /** The finished step goes on in the same host step (a progress-only continuation Run). */
93
+ export function resumeEmittedStep(sessionKey, conversationId) {
94
+ const entry = provenanceBySession.get(sessionKey);
95
+ if (!entry || entry.conversationId !== conversationId || entry.stepPending)
96
+ return;
97
+ entry.unfinished = true;
98
+ }
76
99
  /**
77
100
  * Bind provenance to the conversation a Run was opened on. A reminted
78
101
  * conversation starts with an empty record, so it never inherits the previous
@@ -87,13 +110,19 @@ export function getTurnProvenance(sessionKey) {
87
110
  return undefined;
88
111
  return {
89
112
  conversationId: entry.conversationId,
90
- toolCallIds: [...entry.toolCallIds],
91
- text: entry.text,
92
- textWithReasoning: entry.textWithReasoning,
113
+ ...copyStep(entry),
114
+ ...(entry.unfinished ? { unfinished: true } : {}),
115
+ ...(entry.unfinished && entry.previous ? { previous: copyStep(entry.previous) } : {}),
93
116
  };
94
117
  }
95
118
  export function restoreTurnProvenance(sessionKey, value) {
96
- touch(sessionKey, { ...value, toolCallIds: [...value.toolCallIds], stepPending: false });
119
+ touch(sessionKey, {
120
+ conversationId: value.conversationId,
121
+ ...copyStep(value),
122
+ ...(value.unfinished ? { unfinished: true } : {}),
123
+ ...(value.unfinished && value.previous ? { previous: copyStep(value.previous) } : {}),
124
+ stepPending: false,
125
+ });
97
126
  }
98
127
  export function resetTurnProvenanceForTests() {
99
128
  provenanceBySession.clear();
@@ -101,20 +130,31 @@ export function resetTurnProvenanceForTests() {
101
130
  export function serializeTurnProvenance(value) {
102
131
  return JSON.stringify(value);
103
132
  }
133
+ function parseStep(value) {
134
+ return {
135
+ toolCallIds: Array.isArray(value.toolCallIds)
136
+ ? value.toolCallIds.filter((id) => typeof id === "string").slice(0, MAX_TOOL_CALL_IDS)
137
+ : [],
138
+ text: typeof value.text === "string" ? value.text.slice(0, MAX_TEXT_CHARS) : "",
139
+ textWithReasoning: typeof value.textWithReasoning === "string"
140
+ ? value.textWithReasoning.slice(0, MAX_TEXT_CHARS)
141
+ : "",
142
+ };
143
+ }
104
144
  export function parseTurnProvenance(raw) {
105
145
  try {
106
146
  const value = JSON.parse(raw);
107
147
  if (typeof value.conversationId !== "string" || !value.conversationId)
108
148
  return undefined;
149
+ const unfinished = value.unfinished === true;
150
+ const previous = unfinished && value.previous && typeof value.previous === "object"
151
+ ? parseStep(value.previous)
152
+ : undefined;
109
153
  return {
110
154
  conversationId: value.conversationId,
111
- toolCallIds: Array.isArray(value.toolCallIds)
112
- ? value.toolCallIds.filter((id) => typeof id === "string").slice(0, MAX_TOOL_CALL_IDS)
113
- : [],
114
- text: typeof value.text === "string" ? value.text.slice(0, MAX_TEXT_CHARS) : "",
115
- textWithReasoning: typeof value.textWithReasoning === "string"
116
- ? value.textWithReasoning.slice(0, MAX_TEXT_CHARS)
117
- : "",
155
+ ...parseStep(value),
156
+ ...(unfinished ? { unfinished } : {}),
157
+ ...(previous ? { previous } : {}),
118
158
  };
119
159
  }
120
160
  catch {
@@ -158,17 +198,34 @@ export function detectForeignHistory(input) {
158
198
  // A turn carrying only typed reasoning gives no evidence either way.
159
199
  if (toolCallIds.length === 0 && !text)
160
200
  return undefined;
161
- if (toolCallIds.some((id) => entry.toolCallIds.includes(id)))
162
- return undefined;
201
+ const host = { toolCallIds, text: text.slice(0, MAX_TEXT_CHARS), textWithReasoning: textWithReasoning.slice(0, MAX_TEXT_CHARS) };
163
202
  // Compare against the latest step only: a foreign "Done." must not match an
164
- // older Cursor turn that happened to contain the same words. Same model: the
165
- // host keeps our reasoning typed, so its text parts match our text. After a
166
- // model switch OpenCode 1.x replays that reasoning as text parts in place,
167
- // so the turn matches our reasoning and text in emission order instead.
168
- if (entry.text && text.slice(0, MAX_TEXT_CHARS) === entry.text)
169
- return undefined;
170
- if (entry.textWithReasoning && textWithReasoning.slice(0, MAX_TEXT_CHARS) === entry.textWithReasoning) {
203
+ // older Cursor turn that happened to contain the same words.
204
+ if (sameStep(entry, host))
171
205
  return undefined;
206
+ if (entry.unfinished) {
207
+ // A stopped step: the host drops it when it holds nothing, so its history
208
+ // still ends on the step before. Otherwise it keeps what it had read,
209
+ // which can miss the last parts this provider emitted.
210
+ if (entry.previous && sameStep(entry.previous, host))
211
+ return undefined;
212
+ // A tool call this provider did not emit makes the turn someone else's.
213
+ if (host.toolCallIds.length === 0) {
214
+ if (host.text && entry.text.startsWith(host.text))
215
+ return undefined;
216
+ if (host.textWithReasoning && entry.textWithReasoning.startsWith(host.textWithReasoning))
217
+ return undefined;
218
+ }
172
219
  }
173
220
  return "foreign-assistant";
174
221
  }
222
+ function sameStep(step, host) {
223
+ if (host.toolCallIds.some((id) => step.toolCallIds.includes(id)))
224
+ return true;
225
+ // Same model: the host keeps our reasoning typed, so its text parts match our
226
+ // text. After a model switch OpenCode 1.x replays that reasoning as text
227
+ // parts in place, so the turn matches our reasoning and text in emission order.
228
+ if (step.text && host.text === step.text)
229
+ return true;
230
+ return !!step.textWithReasoning && host.textWithReasoning === step.textWithReasoning;
231
+ }
@@ -1,10 +1,24 @@
1
1
  import type { CursorProviderError } from "./errors.js";
2
- export type ReplayBarrierReason = "visible-text" | "visible-reasoning" | "display-tool-lifecycle" | "non-control-exec" | "stateful-interaction" | "unknown-or-malformed-frame";
2
+ export type ReplayBarrierReason = "visible-text" | "visible-reasoning" | "visible-tool-row" | "display-tool-lifecycle" | "non-control-exec" | "stateful-interaction" | "unknown-or-malformed-frame";
3
+ /** Plain Run-owned state retained across host tool-step pump calls. */
4
+ export type ReplaySafetyState = {
5
+ statefulBarrier?: ReplayBarrierReason;
6
+ /** Every exec needs its owner's host result; ids can restart on a resumed Run. */
7
+ execs?: Array<{
8
+ sessionId: string;
9
+ id: number;
10
+ }>;
11
+ /** History cannot represent opaque interactions, unknown frames or started display work. */
12
+ historyRebaseUnsafe?: boolean;
13
+ };
3
14
  export declare class AttemptReplaySafety {
4
15
  private readonly sessionId;
16
+ private readonly state;
5
17
  private barrierReason;
6
- constructor(sessionId: string);
18
+ private stateful;
19
+ constructor(sessionId: string, state?: ReplaySafetyState);
7
20
  markBarrier(reason: ReplayBarrierReason): void;
21
+ recordExec(id: unknown): void;
8
22
  applyTo(failure: CursorProviderError): CursorProviderError;
9
23
  }
10
24
  export type DecodedReplayFrame = {
@@ -13,11 +27,15 @@ export type DecodedReplayFrame = {
13
27
  kv?: Record<string, unknown>;
14
28
  execControl?: Record<string, unknown>;
15
29
  interactionQuery?: Record<string, unknown>;
16
- checkpointBytes?: Uint8Array;
17
30
  };
18
31
  export type ReplayFrameAnalysis = {
19
- semanticProgress: boolean;
20
32
  barrier?: ReplayBarrierReason;
21
33
  };
34
+ /**
35
+ * Field numbers and wire types of a frame, nested three levels, without any
36
+ * payload bytes: `1:2{16:2{1:0}}`. Compare it with `agent.proto` when a frame
37
+ * is classified unknown.
38
+ */
39
+ export declare function describeFrameLayout(payload: Uint8Array, depth?: number): string;
22
40
  /** Classify one decoded server frame without performing any protocol side effects. */
23
41
  export declare function analyzeReplayFrame(payload: Uint8Array, decoded: DecodedReplayFrame): ReplayFrameAnalysis;
@@ -1,28 +1,71 @@
1
1
  import { trace } from "./debug.js";
2
2
  import { readAllFieldsStrict } from "./protocol/struct.js";
3
+ /** Host output without execution authority: sending the turn again repeats no side effect. */
4
+ const OUTPUT_ONLY_BARRIERS = new Set([
5
+ "visible-text", "visible-reasoning", "visible-tool-row",
6
+ ]);
3
7
  export class AttemptReplaySafety {
4
8
  sessionId;
9
+ state;
5
10
  barrierReason;
6
- constructor(sessionId) {
11
+ stateful = false;
12
+ constructor(sessionId, state = {}) {
7
13
  this.sessionId = sessionId;
14
+ this.state = state;
15
+ this.stateful = state.statefulBarrier !== undefined;
8
16
  }
9
17
  markBarrier(reason) {
18
+ if (!OUTPUT_ONLY_BARRIERS.has(reason)) {
19
+ this.stateful = true;
20
+ this.state.statefulBarrier ??= reason;
21
+ if (reason !== "non-control-exec")
22
+ this.state.historyRebaseUnsafe = true;
23
+ }
10
24
  if (this.barrierReason)
11
25
  return;
12
26
  this.barrierReason = reason;
13
27
  trace(`replay barrier: reason=${reason} sessionId=${this.sessionId}`);
14
28
  }
29
+ recordExec(id) {
30
+ const execs = this.state.execs ??= [];
31
+ if (typeof id !== "number" || !Number.isSafeInteger(id)
32
+ || execs.some(exec => exec.sessionId === this.sessionId && exec.id === id)) {
33
+ this.state.historyRebaseUnsafe = true;
34
+ return;
35
+ }
36
+ execs.push({ sessionId: this.sessionId, id });
37
+ }
15
38
  applyTo(failure) {
16
- failure.replaySafe = this.barrierReason === undefined && failure.replaySafe;
39
+ failure.replaySafe = this.barrierReason === undefined && !this.stateful && failure.replaySafe;
17
40
  if (this.barrierReason) {
18
- trace(`replay suppressed: reason=${this.barrierReason} sessionId=${this.sessionId}`);
41
+ failure.replayBarrier = this.stateful ? "stateful" : "output-only";
42
+ trace(`replay suppressed: reason=${this.barrierReason} barrier=${failure.replayBarrier} ` +
43
+ `sessionId=${this.sessionId}`);
19
44
  }
20
45
  return failure;
21
46
  }
22
47
  }
23
- const INTERACTION_UPDATE_FIELDS = new Set([1, 2, 3, 4, 7, 13, 14, 16, 17]);
48
+ // Field numbers follow Cursor CLI 2026.09.28 `agent.v1`; the extracted
49
+ // agent.proto lags the live server.
50
+ // `InteractionUpdate` oneof members this client recognizes. Besides the
51
+ // declared ones: `thinking_completed` #5 (duration), `token_delta` #8 (count),
52
+ // `tool_call_delta` #15 (argument stream, like `partial_tool_call` #7),
53
+ // `context_injection_state` #23 (status of a client injection, which Cursor
54
+ // CLI only shows for queued steers), `routed_model` #24 (display name) and
55
+ // `tool_requests_listed` #27 (count) carry no output and no state. Other
56
+ // members stay barriers.
57
+ const INTERACTION_UPDATE_FIELDS = new Set([1, 2, 3, 4, 5, 7, 8, 13, 14, 15, 16, 17, 23, 24, 27]);
58
+ /** `InteractionUpdate.message_started_at_ms` #25: a timestamp beside the oneof member. */
59
+ const INTERACTION_UPDATE_STARTED_AT = 25;
24
60
  const INTERACTION_QUERY_FIELDS = new Set([2, 3, 4, 7, 8, 9, 10, 11, 12, 13, 14]);
25
61
  const TOP_LEVEL_FIELDS = new Set([1, 2, 3, 4, 5, 7]);
62
+ /** `AgentServerMessage.ttft_breakdown` #8: timing metrics beside the oneof member. */
63
+ const SERVER_MESSAGE_TTFT = 8;
64
+ /** At most one occurrence of an optional field, with the expected wire type. */
65
+ function optionalField(fields, fn, wt) {
66
+ const matches = fields.filter((field) => field.fn === fn);
67
+ return matches.length <= 1 && matches.every((field) => field.wt === wt);
68
+ }
26
69
  function nestedFields(topLevel, field) {
27
70
  if (topLevel?.fn !== field || topLevel.wt !== 2 || !topLevel.bytes)
28
71
  return [];
@@ -37,8 +80,14 @@ function analyzeExecWire(topLevel) {
37
80
  exactMcpState: exactVariant(36),
38
81
  };
39
82
  }
83
+ // `KvServerMessage` = id #1, oneof get #2 / set #3, `span_context` #4 (tracing);
84
+ // Cursor sends the span on live KV requests.
40
85
  function validKvWire(topLevel) {
41
- const variants = nestedFields(topLevel, 4).filter((field) => field.fn !== 1);
86
+ const fields = nestedFields(topLevel, 4);
87
+ const spans = fields.filter((field) => field.fn === 4);
88
+ if (spans.length > 1 || spans.some((field) => field.wt !== 2))
89
+ return false;
90
+ const variants = fields.filter((field) => field.fn !== 1 && field.fn !== 4);
42
91
  const variant = variants.length === 1 ? variants[0] : undefined;
43
92
  const args = variant?.wt === 2 && variant.bytes
44
93
  ? (readAllFieldsStrict(variant.bytes) ?? [])
@@ -53,14 +102,23 @@ function validKvWire(topLevel) {
53
102
  function validInteractionUpdateWire(topLevel, decoded) {
54
103
  if (!decoded)
55
104
  return true;
56
- const fields = nestedFields(topLevel, 1);
105
+ const all = nestedFields(topLevel, 1);
106
+ if (!optionalField(all, INTERACTION_UPDATE_STARTED_AT, 0))
107
+ return false;
108
+ const fields = all.filter((field) => field.fn !== INTERACTION_UPDATE_STARTED_AT);
57
109
  const update = fields.length === 1 ? fields[0] : undefined;
58
110
  if (!update || update.wt !== 2 || !INTERACTION_UPDATE_FIELDS.has(update.fn))
59
111
  return false;
60
112
  if (![1, 4].includes(update.fn))
61
113
  return true;
62
114
  const delta = update.bytes ? (readAllFieldsStrict(update.bytes) ?? []) : [];
63
- return delta.length === 1 && delta[0].fn === 1 && delta[0].wt === 2;
115
+ const text = delta.filter((field) => field.fn === 1);
116
+ // #2 is `TextDeltaUpdate.is_server_notice` (bool) or
117
+ // `ThinkingDeltaUpdate.thinking_style` (enum); both varints.
118
+ const flag = delta.filter((field) => field.fn === 2);
119
+ return text.length === 1 && text[0].wt === 2
120
+ && optionalField(delta, 2, 0)
121
+ && delta.length === text.length + flag.length;
64
122
  }
65
123
  function validInteractionQueryWire(topLevel, decoded) {
66
124
  if (!decoded)
@@ -81,25 +139,29 @@ function decodedMatchesWire(topLevel, decoded) {
81
139
  || (topLevel?.fn === 5 && !decoded.execControl)
82
140
  || (topLevel?.fn === 7 && !decoded.interactionQuery));
83
141
  }
84
- function hasSemanticProgress(decoded) {
85
- const update = decoded.interactionUpdate;
86
- const text = update?.text_delta?.text;
87
- const thinking = update?.thinking_delta?.text;
88
- return (typeof text === "string" && text.length > 0)
89
- || (typeof thinking === "string" && thinking.length > 0)
90
- || !!update?.turn_ended
91
- || !!update?.tool_call_started
92
- || !!update?.tool_call_completed
93
- || !!decoded.exec
94
- || !!decoded.kv
95
- || !!decoded.execControl
96
- || !!decoded.interactionQuery
97
- || !!decoded.checkpointBytes?.length;
142
+ /**
143
+ * Field numbers and wire types of a frame, nested three levels, without any
144
+ * payload bytes: `1:2{16:2{1:0}}`. Compare it with `agent.proto` when a frame
145
+ * is classified unknown.
146
+ */
147
+ export function describeFrameLayout(payload, depth = 3) {
148
+ const fields = readAllFieldsStrict(payload);
149
+ if (!fields)
150
+ return "?";
151
+ return fields.map((field) => {
152
+ const nested = depth > 1 && field.wt === 2 && field.bytes && field.bytes.length > 0
153
+ ? readAllFieldsStrict(field.bytes)
154
+ : undefined;
155
+ const inner = nested && nested.length > 0 ? `{${describeFrameLayout(field.bytes, depth - 1)}}` : "";
156
+ return `${field.fn}:${field.wt}${inner}`;
157
+ }).join(",");
98
158
  }
99
159
  /** Classify one decoded server frame without performing any protocol side effects. */
100
160
  export function analyzeReplayFrame(payload, decoded) {
101
- const topLevelFields = readAllFieldsStrict(payload) ?? [];
102
- const topLevel = topLevelFields.length === 1 ? topLevelFields[0] : undefined;
161
+ const allTopLevel = readAllFieldsStrict(payload) ?? [];
162
+ const ttftValid = optionalField(allTopLevel, SERVER_MESSAGE_TTFT, 2);
163
+ const topLevelFields = allTopLevel.filter((field) => field.fn !== SERVER_MESSAGE_TTFT);
164
+ const topLevel = ttftValid && topLevelFields.length === 1 ? topLevelFields[0] : undefined;
103
165
  const exec = analyzeExecWire(topLevel);
104
166
  const validKv = validKvWire(topLevel);
105
167
  const malformed = !topLevel
@@ -119,8 +181,5 @@ export function analyzeReplayFrame(payload, decoded) {
119
181
  else if (topLevel.fn === 2 && !exec.exactRequestContext && !exec.exactMcpState) {
120
182
  barrier = "non-control-exec";
121
183
  }
122
- return {
123
- semanticProgress: hasSemanticProgress(decoded),
124
- barrier,
125
- };
184
+ return { barrier };
126
185
  }
@@ -0,0 +1,21 @@
1
+ import { type CursorProviderError } from "./errors.js";
2
+ /** Plain data shared by the plugin and SDK copies, for one logical user turn. */
3
+ export type RetryBudget = {
4
+ server: number;
5
+ transport: number;
6
+ retries: number;
7
+ stallStartedAt?: number;
8
+ /** A plain-text host continuation is admitted only after we requested a host retry. */
9
+ hostRetryPending?: boolean;
10
+ hostRetryKey?: string;
11
+ };
12
+ export declare function newRetryBudget(): RetryBudget;
13
+ /** A host retry or tool continuation must not mint a fresh retry allowance. */
14
+ export declare function turnRetryBudget(sessionKey: string | undefined, turnKey: string, continuationKey?: string): RetryBudget;
15
+ export declare function forgetRetryBudget(sessionKey: string | undefined, budget?: RetryBudget): void;
16
+ export declare function endlessRetriesEnabled(): boolean;
17
+ /** CLI's incomplete-stream error is explicitly transport; an idle deadline is server. */
18
+ export declare function retryCategory(failure: CursorProviderError): "server" | "transport";
19
+ /** Reserve one retry before either reopening locally or handing it to OpenCode. */
20
+ export declare function reserveRetry(budget: RetryBudget, failure: CursorProviderError, maxAttempts?: number, now?: number): void;
21
+ export declare function resetRetryBudgetsForTests(): void;
@@ -0,0 +1,94 @@
1
+ import { CursorRetryExhaustedError } from "./errors.js";
2
+ import { processShared } from "./process-shared.js";
3
+ const budgets = () => processShared("run-retry-budgets.v1", () => new Map());
4
+ const MAX_SESSIONS = 1_024;
5
+ const MAX_TURNS_PER_SESSION = 16;
6
+ export function newRetryBudget() {
7
+ return { server: 0, transport: 0, retries: 0 };
8
+ }
9
+ /** A host retry or tool continuation must not mint a fresh retry allowance. */
10
+ export function turnRetryBudget(sessionKey, turnKey, continuationKey) {
11
+ if (!sessionKey)
12
+ return newRetryBudget();
13
+ const sessions = budgets();
14
+ let turns = sessions.get(sessionKey);
15
+ if (!turns) {
16
+ turns = new Map();
17
+ }
18
+ sessions.delete(sessionKey);
19
+ sessions.set(sessionKey, turns);
20
+ const lookup = (key) => {
21
+ const direct = turns.get(key);
22
+ return direct ? [key, direct] : [...turns].find(([, budget]) => budget.hostRetryKey === key);
23
+ };
24
+ let entry = lookup(turnKey);
25
+ let budget = entry?.[1];
26
+ const currentKey = turnKey;
27
+ if (entry)
28
+ turnKey = entry[0];
29
+ if (!budget && continuationKey !== undefined) {
30
+ entry = lookup(continuationKey);
31
+ const previous = entry?.[1];
32
+ if (previous?.hostRetryPending) {
33
+ budget = previous;
34
+ budget.hostRetryKey = currentKey;
35
+ turnKey = entry[0];
36
+ }
37
+ }
38
+ if (!budget) {
39
+ budget = newRetryBudget();
40
+ }
41
+ budget.hostRetryPending = false;
42
+ turns.delete(turnKey);
43
+ turns.set(turnKey, budget);
44
+ // Abandoned host retries and ephemeral helpers never reach turn_ended.
45
+ // Bound their lookup records; live recovery loops retain their budget object.
46
+ while (turns.size > MAX_TURNS_PER_SESSION)
47
+ turns.delete(turns.keys().next().value);
48
+ while (sessions.size > MAX_SESSIONS)
49
+ sessions.delete(sessions.keys().next().value);
50
+ return budget;
51
+ }
52
+ export function forgetRetryBudget(sessionKey, budget) {
53
+ if (!sessionKey)
54
+ return;
55
+ const sessions = budgets();
56
+ const turns = sessions.get(sessionKey);
57
+ if (!turns)
58
+ return;
59
+ if (!budget)
60
+ sessions.delete(sessionKey);
61
+ else {
62
+ for (const [key, value] of turns)
63
+ if (value === budget)
64
+ turns.delete(key);
65
+ if (turns.size === 0)
66
+ sessions.delete(sessionKey);
67
+ }
68
+ }
69
+ export function endlessRetriesEnabled() {
70
+ return process.env.CURSOR_ENDLESS_RETRIES === "1" || process.env.CURSOR_ENDLESS_RETRIES === "true";
71
+ }
72
+ /** CLI's incomplete-stream error is explicitly transport; an idle deadline is server. */
73
+ export function retryCategory(failure) {
74
+ return failure.origin === "transport"
75
+ && failure.code !== "CURSOR_READ_IDLE_TIMEOUT" ? "transport" : "server";
76
+ }
77
+ /** Reserve one retry before either reopening locally or handing it to OpenCode. */
78
+ export function reserveRetry(budget, failure, maxAttempts, now = Date.now()) {
79
+ const category = retryCategory(failure);
80
+ const stalled = failure.code === "CURSOR_SEMANTIC_IDLE_TIMEOUT";
81
+ if (!stalled)
82
+ budget.stallStartedAt = undefined;
83
+ const stallExpired = stalled && budget.stallStartedAt !== undefined && now - budget.stallStartedAt >= 180_000;
84
+ if (stallExpired || (!endlessRetriesEnabled() && (budget[category] >= (category === "server" ? 3 : 10)
85
+ || (maxAttempts !== undefined && budget.retries >= maxAttempts - 1))))
86
+ throw new CursorRetryExhaustedError(budget.retries + 1, failure);
87
+ if (stalled)
88
+ budget.stallStartedAt ??= now;
89
+ budget[category]++;
90
+ budget.retries++;
91
+ }
92
+ export function resetRetryBudgetsForTests() {
93
+ budgets().clear();
94
+ }
@@ -0,0 +1,23 @@
1
+ export declare const MAX_RUNTIME_WRITE_BYTES: number;
2
+ export declare const MAX_RUNTIME_READ_BYTES: number;
3
+ export declare const RUNTIME_FILE_TTL_MS: number;
4
+ export declare const MAX_RUNTIME_SPILL_BYTES: number;
5
+ export declare const MAX_RUNTIME_SPILL_FILES = 128;
6
+ export type RuntimeLayout = {
7
+ root: string;
8
+ agentTools: string;
9
+ terminals: string;
10
+ };
11
+ /** The one provider-owned layout, pinned by the RequestContext's project root. */
12
+ export declare function runtimeLayout(root: string, create?: boolean): RuntimeLayout;
13
+ export declare function runtimeRootFromContext(context: Record<string, unknown> | undefined): string | undefined;
14
+ export declare function maintainRuntimeFiles(layout: RuntimeLayout, now?: number): void;
15
+ /** Native internal filesystem requests never enter the host's workspace tools. */
16
+ export declare function runtimeExecMessages(message: Record<string, unknown>, context: Record<string, unknown> | undefined, allowTools: boolean): Promise<Uint8Array[] | undefined>;
17
+ /**
18
+ * Age-based cleanup. A live supervisor rewrites its record every second, so a
19
+ * terminal record or job untouched for the TTL is finished, was never
20
+ * started, or was orphaned by a crash or reboot. Only deletions are budgeted:
21
+ * young entries early in directory order cannot stall later cleanup.
22
+ */
23
+ export declare function sweepRuntimeFiles(layout: RuntimeLayout, now?: number): void;