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
package/dist/session.d.ts CHANGED
@@ -3,11 +3,18 @@ import { type CursorProviderError } from "./errors.js";
3
3
  import { type SessionActivitySource } from "./activity.js";
4
4
  import type { HostSubagentCatalog, HostToolDialect, OpencodeToolDef, ToolAliasRegistry } from "./protocol/tools.js";
5
5
  import type { CursorConversationTokenDetails } from "./protocol/token-details.js";
6
+ import type { HostNoteBatch } from "./protocol/host-notes.js";
7
+ import type { RunHistoryImages } from "./protocol/sent-history-images.js";
8
+ import type { ParallelStepState } from "./parallel-step.js";
9
+ import type { OpenCodeModelCost } from "./pricing.js";
10
+ import type { RunCostEstimate } from "./billing.js";
11
+ import type { ReplaySafetyState } from "./replay-safety.js";
6
12
  export type Frame = {
7
13
  flags: number;
8
14
  payload: Uint8Array;
9
15
  };
10
16
  export type CursorContinuationOptions = {
17
+ /** A Run that owes the host nothing fails after this long without any inbound frame. */
11
18
  semanticIdleMs?: number;
12
19
  /** @deprecated Use semanticIdleMs. Kept as a strict alias for compatibility. */
13
20
  softHealthMs?: number;
@@ -50,8 +57,14 @@ export type PendingExec = {
50
57
  * exec result back to Cursor — just clear pending and keep pumping.
51
58
  */
52
59
  bridged?: boolean;
60
+ /**
61
+ * Cursor withdrew the exec (`ExecServerControlMessage.abort`). As in Cursor
62
+ * CLI, nothing is written back for it, but the host's late result still
63
+ * finds this Run so the turn goes on.
64
+ */
65
+ aborted?: boolean;
53
66
  };
54
- export type ContinuationTerminalReason = "hard-cap-expired" | "remote-clean-close" | "remote-error" | "result-write-failed" | "ambiguous-partial-write" | "heartbeat-write-failed" | "reply-write-failed" | "process-disposed" | "superseded-by-new-run" | "open-session-cap-exceeded";
67
+ export type ContinuationTerminalReason = "host-interrupted" | "hard-cap-expired" | "remote-clean-close" | "remote-error" | "result-write-failed" | "ambiguous-partial-write" | "heartbeat-write-failed" | "reply-write-failed" | "process-disposed" | "superseded-by-new-run" | "open-session-cap-exceeded";
55
68
  export type SessionCloseReason = ContinuationTerminalReason | "ordinary-cleanup" | "turn-ended" | "initial-write-failed";
56
69
  export type ContinuationClaim = {
57
70
  session: CursorSession;
@@ -78,7 +91,7 @@ export type DeliveryOutcome = {
78
91
  } | {
79
92
  kind: "duplicate";
80
93
  reason: "in-flight" | "delivered";
81
- framesWritten: 0;
94
+ framesWritten: number;
82
95
  } | {
83
96
  kind: "terminal";
84
97
  reason: ContinuationTerminalReason;
@@ -86,7 +99,7 @@ export type DeliveryOutcome = {
86
99
  } | {
87
100
  kind: "missing";
88
101
  reason: "missing-process-local-state";
89
- framesWritten: 0;
102
+ framesWritten: number;
90
103
  };
91
104
  export type CursorSession = {
92
105
  /**
@@ -95,6 +108,10 @@ export type CursorSession = {
95
108
  * (Cursor resets them per stream) can't cross-deliver results.
96
109
  */
97
110
  sessionId: string;
111
+ /** AgentRunRequest.run_id, for context injections bound to this Run. */
112
+ runId?: string;
113
+ /** Host notes consumed by this Run's initial user action, retained for a rebase. */
114
+ userTurnHostNotes?: HostNoteBatch[];
98
115
  /**
99
116
  * Cursor conversation_id for this Run — used to store/echo
100
117
  * conversation_checkpoint_update (CLI parity).
@@ -104,10 +121,32 @@ export type CursorSession = {
104
121
  cacheDir?: string;
105
122
  /** Latest eligible checkpoint emitted by this Run attempt. */
106
123
  resumeCheckpoint?: Uint8Array;
124
+ /**
125
+ * This Run sent a user message or tool result after its last checkpoint:
126
+ * recovery must not resume earlier state that lacks the delivered input.
127
+ */
128
+ checkpointRevoked?: boolean;
129
+ /** Only a delivered host result, rather than an unrepresented follow-up, revoked it. */
130
+ checkpointRevokedByHostResult?: boolean;
131
+ /** Ignore queued checkpoints received before the latest message/result delivery. */
132
+ resumeCheckpointAfterFrame?: number;
133
+ /** Stateful replay barriers survive host tool-step boundaries on this Run. */
134
+ replaySafety?: ReplaySafetyState;
135
+ /** History images this Run's checkpoints hold or may hold; absent for a Run outside an OpenCode session. */
136
+ historyImages?: RunHistoryImages;
107
137
  /** Last checkpoint-derived context snapshot, independent of retry eligibility. */
108
138
  tokenDetails?: CursorConversationTokenDetails;
109
139
  /** True only after this Run receives a checkpoint containing token details. */
110
140
  tokenDetailsFresh?: boolean;
141
+ /** Cost reporting for this Run's steps (`src/billing.ts`). */
142
+ billing: RunCostEstimate & {
143
+ /** Ledger key: the OpenCode session, or this Run for an ephemeral one. */
144
+ key: string;
145
+ /** Catalog cost entry; absent for a model Cursor publishes no rate for. */
146
+ cost?: OpenCodeModelCost;
147
+ /** Context the previous billed step already sent: read from cache on the next one. */
148
+ prefixTokens: number;
149
+ };
111
150
  /**
112
151
  * Run-lifetime cache evidence. Cursor exposes only aggregate cache counters at
113
152
  * TurnEnded, so retain the inputs and protocol activity needed to explain
@@ -121,6 +160,8 @@ export type CursorSession = {
121
160
  modelId?: string;
122
161
  priorTokenDetails?: CursorConversationTokenDetails;
123
162
  startedWithCheckpoint: boolean;
163
+ /** Why a Run started without a checkpoint: a reset reason, `ephemeral`, or `no-checkpoint`. */
164
+ coldReason?: string;
124
165
  requestContextReused: boolean;
125
166
  requestContextHash: string;
126
167
  systemPromptHash?: string;
@@ -136,6 +177,8 @@ export type CursorSession = {
136
177
  };
137
178
  /** OpenCode session whose own or descendant activity renews tool leases. */
138
179
  openCodeSessionId?: string;
180
+ /** OpenCode session whose Stop cancels this Run, also for a helper isolated from it. */
181
+ stoppedWithSessionId?: string;
139
182
  /** Host primary agent whose prompt/permissions this Run was seeded with. */
140
183
  hostAgent?: string;
141
184
  /** Stable host-system + provider-guidance identity for restart validation. */
@@ -152,8 +195,30 @@ export type CursorSession = {
152
195
  toolCatalog?: OpencodeToolDef[];
153
196
  /** Configured MCP server ids used to split flattened host tool names. */
154
197
  knownMcpServers?: string[];
198
+ /** The Run's model accepts images, so held-Run exec results may carry them. */
199
+ supportsImages?: boolean;
155
200
  stream: BidiStream;
156
201
  frames: AsyncIterator<Frame>;
202
+ /** An iterator read retained across an idle-guard timeout. */
203
+ pendingFrameRead?: Promise<IteratorResult<Frame>>;
204
+ /**
205
+ * One-slot pushback for a terminal update that must start the next pump pass
206
+ * after the host settles emitted tools. Timed-out reads use pendingFrameRead.
207
+ */
208
+ pushbackFrame?: Frame;
209
+ /**
210
+ * Frames the held-Run reader took while the host ran tools and the next pump
211
+ * must see, in arrival order. They follow `pushbackFrame` and precede any
212
+ * unread frame.
213
+ */
214
+ heldFrames?: Frame[];
215
+ /**
216
+ * In-progress parallel tool-call step (field 27 hold). Cleared when the step
217
+ * finishes; added to priorParallelStepCallIds for late-completion filtering.
218
+ */
219
+ parallelStep?: ParallelStepState;
220
+ /** Resolved call ids from prior steps of this Run — ignore late completions. */
221
+ priorParallelStepCallIds?: Set<string>;
157
222
  pending: Map<number, PendingExec>;
158
223
  /**
159
224
  * Cursor display tool calls (tool_call_started) awaiting either an exec or a
@@ -167,6 +232,11 @@ export type CursorSession = {
167
232
  * completed list so host todos still receive a replace-all snapshot.
168
233
  */
169
234
  mirroredTodos?: Array<Record<string, unknown>>;
235
+ /**
236
+ * CreatePlan display calls whose plan was deferred to the host plan agent.
237
+ * Their completed display carries no recorded plan, so it is not mirrored.
238
+ */
239
+ deferredCreatePlanCalls?: Set<string>;
170
240
  /**
171
241
  * Legacy edit calls whose authoritative exec path is still in progress.
172
242
  * Cursor implements these as read -> whole-file write; retaining the path
@@ -231,13 +301,18 @@ export type CursorSession = {
231
301
  deferredTerminalReason: "remote-clean-close" | "remote-error" | null;
232
302
  policy: CursorContinuationPolicy;
233
303
  createdAt: number;
304
+ /** When the host's model call that opened this Run started; it may open the Run after a stop. */
305
+ requestedAt?: number;
234
306
  lastInboundAt: number;
235
307
  lastHeartbeatWriteAt: number;
236
308
  semanticDeadlineAt: number;
237
309
  closeError: CursorProviderError | null;
238
310
  closed: boolean;
239
- reopenWithUserMessage?: (text: string) => Promise<void>;
311
+ /** The host stopped the turn (OpenCode 2 interrupt); the Run is being cancelled and must not be continued. */
312
+ hostInterrupted?: string;
313
+ reopenWithUserMessage?: (text: string, signal?: AbortSignal) => Promise<void>;
240
314
  };
315
+ export declare function stoppedWith(session: CursorSession): string | undefined;
241
316
  type SessionManagerOptions = {
242
317
  now?: () => number;
243
318
  setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
@@ -248,10 +323,14 @@ type SessionManagerOptions = {
248
323
  maxOpenSessions?: number;
249
324
  };
250
325
  export declare class SessionManager {
326
+ /** A claimed result has one writer even while that writer awaits drain. */
327
+ private readonly delivering;
251
328
  private byExecId;
252
329
  private sessions;
253
330
  private byOpenCodeSessionId;
254
331
  private tombstones;
332
+ /** Early-row tool call id → the exec that bound it. Bounded like the tombstones. */
333
+ private toolCallRoutes;
255
334
  private readonly now;
256
335
  private readonly setTimer;
257
336
  private readonly clearTimer;
@@ -264,12 +343,24 @@ export declare class SessionManager {
264
343
  /** Live open Run for this OpenCode session id, if any. */
265
344
  findOpenByOpenCodeSessionId(openCodeSessionId: string | undefined): CursorSession | undefined;
266
345
  isActivelyPumping(session: CursorSession): boolean;
346
+ /** Every open Run a Stop of this OpenCode session cancels, including one a newer Run did not supersede. */
347
+ openSessionsStoppedWith(openCodeSessionId: string | undefined): CursorSession[];
348
+ /**
349
+ * Forget every result the host still owed this Run, without writing any: the
350
+ * host stopped the turn, and the Run is about to be cancelled.
351
+ *
352
+ * @returns number of pendings dropped
353
+ */
354
+ abandonPending(session: CursorSession): number;
355
+ /** Cursor withdrew this exec; returns false when it is not one the host still owes. */
356
+ markExecAborted(session: CursorSession, execId: number): boolean;
267
357
  /**
268
- * Clear display-only bridged pendings that do not require a Cursor exec write.
269
- * Used when the host starts a fresh user turn instead of returning the bridged
270
- * tool result (e.g. human `continue` while a todowrite mirror is outstanding).
358
+ * Clear pendings that do not require a Cursor exec write: display-only bridged
359
+ * ones and execs Cursor aborted. Used when the host starts a fresh user turn
360
+ * instead of returning the result (e.g. human `continue` while a todowrite
361
+ * mirror is outstanding).
271
362
  *
272
- * @returns number of bridged pendings settled
363
+ * @returns number of pendings settled
273
364
  */
274
365
  settleBridgedPending(session: CursorSession): number;
275
366
  private isPumping;
@@ -289,7 +380,9 @@ export declare class SessionManager {
289
380
  pendingFor(sessionId: string, execId: number): PendingExec | undefined;
290
381
  classify(sessionId: string, execId: number): ContinuationClassification;
291
382
  claim(sessionId: string, execId: number): ContinuationClaim | ContinuationClassification;
292
- deliverClaim(claim: ContinuationClaim, frames: readonly Uint8Array[]): DeliveryOutcome;
383
+ deliverClaim(claim: ContinuationClaim, frames: readonly Uint8Array[],
384
+ /** Called at each actual serialized write, before write() and its drain. */
385
+ beforeWrite?: () => void): Promise<DeliveryOutcome>;
293
386
  /** Find the live session awaiting one of the given exec ids. */
294
387
  findByExecIds(sessionId: string, execIds: number[]): CursorSession | undefined;
295
388
  /** Mark an exec id as resolved (its result has been delivered). */
@@ -302,7 +395,7 @@ export declare class SessionManager {
302
395
  * Callers must cancel the session heartbeat and wait for the old stream's
303
396
  * write chain before this, so an in-flight heartbeat cannot close the session.
304
397
  */
305
- replaceStream(session: CursorSession, next: BidiStream): void;
398
+ replaceStream(session: CursorSession, next: BidiStream, runId?: string): void;
306
399
  private subscribeTerminal;
307
400
  private key;
308
401
  close(session: CursorSession, reason?: SessionCloseReason, error?: CursorProviderError): void;
@@ -318,6 +411,22 @@ export declare class SessionManager {
318
411
  dispose(): void;
319
412
  sweepHardDeadlines(): void;
320
413
  private onStreamTerminal;
414
+ /**
415
+ * The host tool-call id a continuation will send for this exec, when it is
416
+ * not `cursor_<session>_<execId>` (an early row bound to the real exec).
417
+ * The route outlives its Run, like a tombstone, so a result that arrives
418
+ * after the Run closed still names its exec and rebuilds from host history.
419
+ */
420
+ bindToolCall(toolCallId: string, sessionId: string, execId: number): void;
421
+ toolCallExec(toolCallId: string): {
422
+ sessionId: string;
423
+ execId: number;
424
+ } | undefined;
425
+ /**
426
+ * Leases only grow. Session activity renews from its own time; a running
427
+ * host tool renews from now, but never past that tool's own ceiling. The
428
+ * ceiling limits the tool's extension, not the lease activity already gave.
429
+ */
321
430
  private refreshHardDeadline;
322
431
  private hardDeadlineExpired;
323
432
  private scheduleHardDeadline;
@@ -325,5 +434,15 @@ export declare class SessionManager {
325
434
  private putTombstone;
326
435
  private isTerminalReason;
327
436
  }
437
+ /**
438
+ * Read one frame, retaining ownership of the iterator read when a timeout
439
+ * wins. Every reader uses this function so a continuation cannot overtake a
440
+ * late frame (or discard its EOF/error).
441
+ */
442
+ export declare function readSessionFrame(session: CursorSession): Promise<IteratorResult<Frame>>;
443
+ export declare function readSessionFrame(session: CursorSession, timeoutMs: number): Promise<IteratorResult<Frame> | {
444
+ done: true;
445
+ timedOut: true;
446
+ }>;
328
447
  export declare const sessionManager: SessionManager;
329
448
  export {};
package/dist/session.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { trace } from "./debug.js";
2
+ import { releaseUnstartedTerminal } from "./terminal-runner.js";
2
3
  import { CursorProtocolError } from "./errors.js";
3
- import { sessionActivity } from "./activity.js";
4
+ import { StreamWriteDrainError, writeStreamMessage } from "./stream-write.js";
5
+ import { RUNNING_TOOL_CAP_MS, sessionActivity } from "./activity.js";
4
6
  export const DEFAULT_CONTINUATION_POLICY = {
5
- semanticIdleMs: 120_000,
7
+ // Cursor CLI aborts an active attempt after 30 s without an inbound frame.
8
+ semanticIdleMs: 30_000,
6
9
  hardCapMs: 600_000,
7
10
  heartbeatMs: 5_000,
8
11
  };
@@ -33,13 +36,15 @@ export function resolveContinuationPolicy(options) {
33
36
  throw new CursorProtocolError(`Unknown Cursor continuation option: ${key}`);
34
37
  }
35
38
  }
39
+ // Read the deprecated alias once, without the deprecated typed accessor.
40
+ const legacySemanticIdleMs = options?.softHealthMs;
36
41
  if (options?.semanticIdleMs !== undefined &&
37
- options.softHealthMs !== undefined &&
38
- options.semanticIdleMs !== options.softHealthMs) {
42
+ legacySemanticIdleMs !== undefined &&
43
+ options.semanticIdleMs !== legacySemanticIdleMs) {
39
44
  throw new CursorProtocolError("Cursor continuation semanticIdleMs and deprecated softHealthMs must match when both are set");
40
45
  }
41
46
  const heartbeatMs = positiveInteger("heartbeatMs", options?.heartbeatMs, DEFAULT_CONTINUATION_POLICY.heartbeatMs);
42
- const semanticIdleMs = positiveInteger("semanticIdleMs", options?.semanticIdleMs ?? options?.softHealthMs, DEFAULT_CONTINUATION_POLICY.semanticIdleMs);
47
+ const semanticIdleMs = positiveInteger("semanticIdleMs", options?.semanticIdleMs ?? legacySemanticIdleMs, DEFAULT_CONTINUATION_POLICY.semanticIdleMs);
43
48
  const hardCapMs = positiveInteger("hardCapMs", options?.hardCapMs, DEFAULT_CONTINUATION_POLICY.hardCapMs);
44
49
  if (heartbeatMs >= semanticIdleMs) {
45
50
  throw new CursorProtocolError("Cursor continuation heartbeatMs must be less than semanticIdleMs");
@@ -49,7 +54,12 @@ export function resolveContinuationPolicy(options) {
49
54
  }
50
55
  return { heartbeatMs, semanticIdleMs, hardCapMs };
51
56
  }
57
+ export function stoppedWith(session) {
58
+ return session.stoppedWithSessionId ?? session.openCodeSessionId;
59
+ }
52
60
  export class SessionManager {
61
+ /** A claimed result has one writer even while that writer awaits drain. */
62
+ delivering = new WeakSet();
53
63
  // Composite key `${sessionId}:${execId}` → owning session. Composite keying
54
64
  // means two Run streams that both register an execId of 1 (Cursor resets
55
65
  // counters per stream) coexist instead of overwriting each other.
@@ -60,6 +70,8 @@ export class SessionManager {
60
70
  // of continuing the held-open one — the prior entry is stale and superseded.
61
71
  byOpenCodeSessionId = new Map();
62
72
  tombstones = new Map();
73
+ /** Early-row tool call id → the exec that bound it. Bounded like the tombstones. */
74
+ toolCallRoutes = new Map();
63
75
  now;
64
76
  setTimer;
65
77
  clearTimer;
@@ -146,19 +158,54 @@ export class SessionManager {
146
158
  isActivelyPumping(session) {
147
159
  return this.isPumping(session);
148
160
  }
161
+ /** Every open Run a Stop of this OpenCode session cancels, including one a newer Run did not supersede. */
162
+ openSessionsStoppedWith(openCodeSessionId) {
163
+ return [...this.sessions].filter((session) => !session.closed && (openCodeSessionId === undefined || stoppedWith(session) === openCodeSessionId));
164
+ }
165
+ /**
166
+ * Forget every result the host still owed this Run, without writing any: the
167
+ * host stopped the turn, and the Run is about to be cancelled.
168
+ *
169
+ * @returns number of pendings dropped
170
+ */
171
+ abandonPending(session) {
172
+ if (session.closed)
173
+ return 0;
174
+ const dropped = session.pending.size;
175
+ for (const [execId, pending] of session.pending) {
176
+ const directory = pending.resultMetadata?.managed_job_directory;
177
+ if (typeof directory === "string")
178
+ releaseUnstartedTerminal(directory);
179
+ const key = this.key(session.sessionId, execId);
180
+ this.byExecId.delete(key);
181
+ this.putTombstone(key, "host-interrupted");
182
+ }
183
+ session.pending.clear();
184
+ this.scheduleHardDeadline(session);
185
+ return dropped;
186
+ }
187
+ /** Cursor withdrew this exec; returns false when it is not one the host still owes. */
188
+ markExecAborted(session, execId) {
189
+ const pending = session.pending.get(execId);
190
+ if (session.closed || !pending || pending.bridged || pending.aborted || pending.state !== "pending")
191
+ return false;
192
+ pending.aborted = true;
193
+ return true;
194
+ }
149
195
  /**
150
- * Clear display-only bridged pendings that do not require a Cursor exec write.
151
- * Used when the host starts a fresh user turn instead of returning the bridged
152
- * tool result (e.g. human `continue` while a todowrite mirror is outstanding).
196
+ * Clear pendings that do not require a Cursor exec write: display-only bridged
197
+ * ones and execs Cursor aborted. Used when the host starts a fresh user turn
198
+ * instead of returning the result (e.g. human `continue` while a todowrite
199
+ * mirror is outstanding).
153
200
  *
154
- * @returns number of bridged pendings settled
201
+ * @returns number of pendings settled
155
202
  */
156
203
  settleBridgedPending(session) {
157
204
  if (session.closed)
158
205
  return 0;
159
206
  let settled = 0;
160
207
  for (const [execId, pending] of [...session.pending.entries()]) {
161
- if (!pending.bridged)
208
+ if (!pending.bridged && !pending.aborted)
162
209
  continue;
163
210
  if (pending.state === "claimed")
164
211
  continue;
@@ -192,8 +239,9 @@ export class SessionManager {
192
239
  if (candidate === justRegistered || this.isPumping(candidate))
193
240
  continue;
194
241
  // Held-open Runs with pending tools must not be cap-evicted — that is
195
- // mid-exec supersede by another name and throws away the prefix.
196
- if (candidate.pending.size > 0)
242
+ // mid-exec supersede by another name and throws away the prefix. An
243
+ // exec Cursor aborted no longer waits on the host, so it does not hold.
244
+ if ([...candidate.pending.values()].some((pending) => !pending.aborted))
197
245
  continue;
198
246
  if (!oldest || candidate.createdAt < oldest.createdAt)
199
247
  oldest = candidate;
@@ -291,20 +339,25 @@ export class SessionManager {
291
339
  pending: classification.pending,
292
340
  };
293
341
  }
294
- deliverClaim(claim, frames) {
342
+ async deliverClaim(claim, frames,
343
+ /** Called at each actual serialized write, before write() and its drain. */
344
+ beforeWrite) {
295
345
  const { session, execId, pending } = claim;
296
346
  const key = this.key(session.sessionId, execId);
297
- if (session.closed ||
298
- this.byExecId.get(key) !== session ||
299
- session.pending.get(execId) !== pending) {
347
+ const unavailable = (framesWritten) => {
348
+ if (!session.closed &&
349
+ this.byExecId.get(key) === session &&
350
+ session.pending.get(execId) === pending)
351
+ return undefined;
300
352
  const current = this.classify(session.sessionId, execId);
301
- if (current.kind === "duplicate")
302
- return { ...current, framesWritten: 0 };
303
- if (current.kind === "terminal")
304
- return { ...current, framesWritten: 0 };
305
- return { kind: "missing", reason: "missing-process-local-state", framesWritten: 0 };
306
- }
307
- if (pending.state !== "claimed") {
353
+ if (current.kind === "duplicate" || current.kind === "terminal")
354
+ return { ...current, framesWritten };
355
+ return { kind: "missing", reason: "missing-process-local-state", framesWritten };
356
+ };
357
+ const invalid = unavailable(0);
358
+ if (invalid)
359
+ return invalid;
360
+ if (pending.state !== "claimed" || this.delivering.has(pending)) {
308
361
  return {
309
362
  kind: "duplicate",
310
363
  reason: pending.state === "delivered" ? "delivered" : "in-flight",
@@ -315,17 +368,37 @@ export class SessionManager {
315
368
  this.close(session, "hard-cap-expired");
316
369
  return { kind: "terminal", reason: "hard-cap-expired", framesWritten: 0 };
317
370
  }
371
+ this.delivering.add(pending);
318
372
  let framesWritten = 0;
319
373
  try {
320
- if (!pending.bridged && frames.length === 0) {
374
+ if (!pending.bridged && !pending.aborted && frames.length === 0) {
321
375
  throw new CursorProtocolError("No result frames were produced");
322
376
  }
323
377
  for (const frame of frames) {
324
- session.stream.write(frame);
378
+ try {
379
+ await writeStreamMessage(session.stream, frame, "continuation result", beforeWrite ? () => {
380
+ beforeWrite();
381
+ return true;
382
+ } : undefined);
383
+ }
384
+ catch (error) {
385
+ // write() returned false before the drain failed: the bytes are buffered.
386
+ if (error instanceof StreamWriteDrainError)
387
+ framesWritten++;
388
+ throw error;
389
+ }
325
390
  framesWritten++;
391
+ // The Run can close or replace this pending exec while drain is awaited.
392
+ // Never send its remaining frames or apply approval/state to that owner.
393
+ const invalid = unavailable(framesWritten);
394
+ if (invalid)
395
+ return invalid;
326
396
  }
327
397
  }
328
398
  catch {
399
+ const invalid = unavailable(framesWritten);
400
+ if (invalid)
401
+ return invalid;
329
402
  const reason = framesWritten === 0 ? "result-write-failed" : "ambiguous-partial-write";
330
403
  this.close(session, reason);
331
404
  return { kind: "terminal", reason, framesWritten };
@@ -377,7 +450,8 @@ export class SessionManager {
377
450
  session.pumpActive = false;
378
451
  const deferred = session.deferredTerminalReason;
379
452
  session.deferredTerminalReason = null;
380
- if (deferred)
453
+ // A Run the host stopped is closed by its cancel, after it has read what Cursor sent last.
454
+ if (deferred && !session.hostInterrupted)
381
455
  this.close(session, deferred);
382
456
  return true;
383
457
  }
@@ -386,7 +460,7 @@ export class SessionManager {
386
460
  * Callers must cancel the session heartbeat and wait for the old stream's
387
461
  * write chain before this, so an in-flight heartbeat cannot close the session.
388
462
  */
389
- replaceStream(session, next) {
463
+ replaceStream(session, next, runId) {
390
464
  if (session.closed)
391
465
  throw new CursorProtocolError("Cannot replace stream on a closed Cursor session");
392
466
  session.heartbeatCancel?.();
@@ -395,7 +469,17 @@ export class SessionManager {
395
469
  session.deferredTerminalReason = null;
396
470
  const old = session.stream;
397
471
  session.stream = next;
472
+ // Preserve session totals but give the replacement Run its own estimates.
473
+ session.billing.estimatedUsd = 0;
474
+ delete session.billing.ledgerEpoch;
475
+ if (runId !== undefined)
476
+ session.runId = runId;
398
477
  session.frames = next.frames()[Symbol.asyncIterator]();
478
+ session.pendingFrameRead = undefined;
479
+ session.pushbackFrame = undefined;
480
+ session.heldFrames = undefined;
481
+ session.parallelStep = undefined;
482
+ session.priorParallelStepCallIds = undefined;
399
483
  this.subscribeTerminal(session);
400
484
  try {
401
485
  old.destroy();
@@ -437,13 +521,21 @@ export class SessionManager {
437
521
  session.terminalUnsubscribe?.();
438
522
  session.terminalUnsubscribe = null;
439
523
  session.deferredTerminalReason = null;
440
- for (const id of session.pending.keys()) {
524
+ for (const [id, pending] of session.pending) {
525
+ const directory = pending.resultMetadata?.managed_job_directory;
526
+ if (typeof directory === "string")
527
+ releaseUnstartedTerminal(directory);
441
528
  const key = this.key(session.sessionId, id);
442
529
  this.byExecId.delete(key);
443
530
  if (this.isTerminalReason(reason))
444
531
  this.putTombstone(key, reason);
445
532
  }
446
533
  session.pending.clear();
534
+ session.pendingFrameRead = undefined;
535
+ session.pushbackFrame = undefined;
536
+ session.heldFrames = undefined;
537
+ session.parallelStep = undefined;
538
+ session.priorParallelStepCallIds = undefined;
447
539
  session.pumpOwner = null;
448
540
  session.pumpActive = false;
449
541
  session.displayToolCalls?.clear();
@@ -482,6 +574,7 @@ export class SessionManager {
482
574
  for (const session of [...this.sessions])
483
575
  this.close(session, "process-disposed");
484
576
  this.byExecId.clear();
577
+ this.toolCallRoutes.clear();
485
578
  }
486
579
  sweepHardDeadlines() {
487
580
  for (const session of [...this.sessions]) {
@@ -497,22 +590,55 @@ export class SessionManager {
497
590
  const reason = event.kind === "remote-error" ? "remote-error" : "remote-clean-close";
498
591
  if (event.kind === "remote-error")
499
592
  session.closeError = event.error;
500
- if (session.pumpOwner !== null) {
593
+ if (session.pumpOwner !== null || session.hostInterrupted) {
501
594
  session.deferredTerminalReason = reason;
502
595
  return;
503
596
  }
504
597
  this.close(session, reason, event.kind === "remote-error" ? event.error : undefined);
505
598
  }
599
+ /**
600
+ * The host tool-call id a continuation will send for this exec, when it is
601
+ * not `cursor_<session>_<execId>` (an early row bound to the real exec).
602
+ * The route outlives its Run, like a tombstone, so a result that arrives
603
+ * after the Run closed still names its exec and rebuilds from host history.
604
+ */
605
+ bindToolCall(toolCallId, sessionId, execId) {
606
+ this.toolCallRoutes.delete(toolCallId);
607
+ this.toolCallRoutes.set(toolCallId, { sessionId, execId });
608
+ while (this.toolCallRoutes.size > this.tombstoneLimit) {
609
+ const oldest = this.toolCallRoutes.keys().next().value;
610
+ if (oldest === undefined)
611
+ break;
612
+ this.toolCallRoutes.delete(oldest);
613
+ }
614
+ }
615
+ toolCallExec(toolCallId) {
616
+ return this.toolCallRoutes.get(toolCallId);
617
+ }
618
+ /**
619
+ * Leases only grow. Session activity renews from its own time; a running
620
+ * host tool renews from now, but never past that tool's own ceiling. The
621
+ * ceiling limits the tool's extension, not the lease activity already gave.
622
+ */
506
623
  refreshHardDeadline(session, pending) {
507
624
  if (!session.openCodeSessionId)
508
625
  return pending.hardDeadlineAt;
626
+ const now = this.now();
627
+ const toolCapAt = this.activitySource.runningToolDeadline?.(session.openCodeSessionId, now, RUNNING_TOOL_CAP_MS);
509
628
  const activityAt = this.activitySource.lastActivityAt(session.openCodeSessionId);
510
- if (activityAt === undefined || activityAt <= pending.registeredAt)
511
- return pending.hardDeadlineAt;
512
- const renewedDeadline = activityAt + session.policy.hardCapMs;
513
- if (renewedDeadline > pending.hardDeadlineAt) {
514
- pending.hardDeadlineAt = renewedDeadline;
515
- trace("continuation lease renewed from OpenCode session activity");
629
+ if (activityAt !== undefined && activityAt > pending.registeredAt) {
630
+ const renewedDeadline = activityAt + session.policy.hardCapMs;
631
+ if (renewedDeadline > pending.hardDeadlineAt) {
632
+ pending.hardDeadlineAt = renewedDeadline;
633
+ trace("continuation lease renewed from OpenCode session activity");
634
+ }
635
+ }
636
+ if (toolCapAt !== undefined) {
637
+ const renewedDeadline = Math.min(now + session.policy.hardCapMs, toolCapAt);
638
+ if (renewedDeadline > pending.hardDeadlineAt) {
639
+ pending.hardDeadlineAt = renewedDeadline;
640
+ trace("continuation lease renewed while a host tool is running");
641
+ }
516
642
  }
517
643
  return pending.hardDeadlineAt;
518
644
  }
@@ -570,6 +696,43 @@ export class SessionManager {
570
696
  return !["ordinary-cleanup", "turn-ended", "initial-write-failed"].includes(reason);
571
697
  }
572
698
  }
699
+ export async function readSessionFrame(session, timeoutMs) {
700
+ if (session.pushbackFrame) {
701
+ const value = session.pushbackFrame;
702
+ session.pushbackFrame = undefined;
703
+ return { done: false, value };
704
+ }
705
+ const held = session.heldFrames?.shift();
706
+ if (held)
707
+ return { done: false, value: held };
708
+ const pending = session.pendingFrameRead ??= session.frames.next();
709
+ // A timed-out read can reject while no pump is consuming it. Preserve the
710
+ // rejection for the next reader without an unhandled detached promise.
711
+ void pending.catch(() => { });
712
+ let timer;
713
+ try {
714
+ const result = timeoutMs === undefined ? await pending : await Promise.race([
715
+ pending,
716
+ new Promise(resolve => {
717
+ timer = setTimeout(() => resolve({ done: true, timedOut: true }), timeoutMs);
718
+ timer.unref?.();
719
+ }),
720
+ ]);
721
+ if (!("timedOut" in result) && session.pendingFrameRead === pending) {
722
+ session.pendingFrameRead = undefined;
723
+ }
724
+ return result;
725
+ }
726
+ catch (error) {
727
+ if (session.pendingFrameRead === pending)
728
+ session.pendingFrameRead = undefined;
729
+ throw error;
730
+ }
731
+ finally {
732
+ if (timer)
733
+ clearTimeout(timer);
734
+ }
735
+ }
573
736
  export const sessionManager = new SessionManager();
574
737
  function installProcessCleanup() {
575
738
  const dispose = () => {
package/dist/shared.d.ts CHANGED
@@ -21,7 +21,7 @@ export declare const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableMo
21
21
  export declare const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
22
22
  export declare const MODEL_CACHE_FILE = "cursor-models.json";
23
23
  /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
24
- export declare const MODEL_CACHE_SCHEMA_VERSION = 3;
24
+ export declare const MODEL_CACHE_SCHEMA_VERSION = 6;
25
25
  export declare const MODEL_CACHE_TTL_MS = 86400000;
26
26
  export declare const CONVERSATION_CACHE_DIR = "cursor-conversations";
27
27
  export declare const CONVERSATION_CACHE_SCHEMA_VERSION = 4;
package/dist/shared.js CHANGED
@@ -21,7 +21,7 @@ export const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
21
21
  export const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
22
22
  export const MODEL_CACHE_FILE = "cursor-models.json";
23
23
  /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
24
- export const MODEL_CACHE_SCHEMA_VERSION = 3;
24
+ export const MODEL_CACHE_SCHEMA_VERSION = 6;
25
25
  export const MODEL_CACHE_TTL_MS = 86_400_000;
26
26
  export const CONVERSATION_CACHE_DIR = "cursor-conversations";
27
27
  export const CONVERSATION_CACHE_SCHEMA_VERSION = 4;