cursor-opencode-provider 0.8.1 → 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 (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  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 +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
package/dist/session.d.ts CHANGED
@@ -3,12 +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";
6
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";
7
12
  export type Frame = {
8
13
  flags: number;
9
14
  payload: Uint8Array;
10
15
  };
11
16
  export type CursorContinuationOptions = {
17
+ /** A Run that owes the host nothing fails after this long without any inbound frame. */
12
18
  semanticIdleMs?: number;
13
19
  /** @deprecated Use semanticIdleMs. Kept as a strict alias for compatibility. */
14
20
  softHealthMs?: number;
@@ -51,8 +57,14 @@ export type PendingExec = {
51
57
  * exec result back to Cursor — just clear pending and keep pumping.
52
58
  */
53
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;
54
66
  };
55
- 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";
56
68
  export type SessionCloseReason = ContinuationTerminalReason | "ordinary-cleanup" | "turn-ended" | "initial-write-failed";
57
69
  export type ContinuationClaim = {
58
70
  session: CursorSession;
@@ -79,7 +91,7 @@ export type DeliveryOutcome = {
79
91
  } | {
80
92
  kind: "duplicate";
81
93
  reason: "in-flight" | "delivered";
82
- framesWritten: 0;
94
+ framesWritten: number;
83
95
  } | {
84
96
  kind: "terminal";
85
97
  reason: ContinuationTerminalReason;
@@ -87,7 +99,7 @@ export type DeliveryOutcome = {
87
99
  } | {
88
100
  kind: "missing";
89
101
  reason: "missing-process-local-state";
90
- framesWritten: 0;
102
+ framesWritten: number;
91
103
  };
92
104
  export type CursorSession = {
93
105
  /**
@@ -98,6 +110,8 @@ export type CursorSession = {
98
110
  sessionId: string;
99
111
  /** AgentRunRequest.run_id, for context injections bound to this Run. */
100
112
  runId?: string;
113
+ /** Host notes consumed by this Run's initial user action, retained for a rebase. */
114
+ userTurnHostNotes?: HostNoteBatch[];
101
115
  /**
102
116
  * Cursor conversation_id for this Run — used to store/echo
103
117
  * conversation_checkpoint_update (CLI parity).
@@ -107,10 +121,32 @@ export type CursorSession = {
107
121
  cacheDir?: string;
108
122
  /** Latest eligible checkpoint emitted by this Run attempt. */
109
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;
110
137
  /** Last checkpoint-derived context snapshot, independent of retry eligibility. */
111
138
  tokenDetails?: CursorConversationTokenDetails;
112
139
  /** True only after this Run receives a checkpoint containing token details. */
113
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
+ };
114
150
  /**
115
151
  * Run-lifetime cache evidence. Cursor exposes only aggregate cache counters at
116
152
  * TurnEnded, so retain the inputs and protocol activity needed to explain
@@ -124,6 +160,8 @@ export type CursorSession = {
124
160
  modelId?: string;
125
161
  priorTokenDetails?: CursorConversationTokenDetails;
126
162
  startedWithCheckpoint: boolean;
163
+ /** Why a Run started without a checkpoint: a reset reason, `ephemeral`, or `no-checkpoint`. */
164
+ coldReason?: string;
127
165
  requestContextReused: boolean;
128
166
  requestContextHash: string;
129
167
  systemPromptHash?: string;
@@ -139,6 +177,8 @@ export type CursorSession = {
139
177
  };
140
178
  /** OpenCode session whose own or descendant activity renews tool leases. */
141
179
  openCodeSessionId?: string;
180
+ /** OpenCode session whose Stop cancels this Run, also for a helper isolated from it. */
181
+ stoppedWithSessionId?: string;
142
182
  /** Host primary agent whose prompt/permissions this Run was seeded with. */
143
183
  hostAgent?: string;
144
184
  /** Stable host-system + provider-guidance identity for restart validation. */
@@ -166,6 +206,12 @@ export type CursorSession = {
166
206
  * after the host settles emitted tools. Timed-out reads use pendingFrameRead.
167
207
  */
168
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[];
169
215
  /**
170
216
  * In-progress parallel tool-call step (field 27 hold). Cleared when the step
171
217
  * finishes; added to priorParallelStepCallIds for late-completion filtering.
@@ -255,13 +301,18 @@ export type CursorSession = {
255
301
  deferredTerminalReason: "remote-clean-close" | "remote-error" | null;
256
302
  policy: CursorContinuationPolicy;
257
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;
258
306
  lastInboundAt: number;
259
307
  lastHeartbeatWriteAt: number;
260
308
  semanticDeadlineAt: number;
261
309
  closeError: CursorProviderError | null;
262
310
  closed: boolean;
263
- 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>;
264
314
  };
315
+ export declare function stoppedWith(session: CursorSession): string | undefined;
265
316
  type SessionManagerOptions = {
266
317
  now?: () => number;
267
318
  setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
@@ -272,10 +323,14 @@ type SessionManagerOptions = {
272
323
  maxOpenSessions?: number;
273
324
  };
274
325
  export declare class SessionManager {
326
+ /** A claimed result has one writer even while that writer awaits drain. */
327
+ private readonly delivering;
275
328
  private byExecId;
276
329
  private sessions;
277
330
  private byOpenCodeSessionId;
278
331
  private tombstones;
332
+ /** Early-row tool call id → the exec that bound it. Bounded like the tombstones. */
333
+ private toolCallRoutes;
279
334
  private readonly now;
280
335
  private readonly setTimer;
281
336
  private readonly clearTimer;
@@ -288,12 +343,24 @@ export declare class SessionManager {
288
343
  /** Live open Run for this OpenCode session id, if any. */
289
344
  findOpenByOpenCodeSessionId(openCodeSessionId: string | undefined): CursorSession | undefined;
290
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;
291
357
  /**
292
- * Clear display-only bridged pendings that do not require a Cursor exec write.
293
- * Used when the host starts a fresh user turn instead of returning the bridged
294
- * 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).
295
362
  *
296
- * @returns number of bridged pendings settled
363
+ * @returns number of pendings settled
297
364
  */
298
365
  settleBridgedPending(session: CursorSession): number;
299
366
  private isPumping;
@@ -313,7 +380,9 @@ export declare class SessionManager {
313
380
  pendingFor(sessionId: string, execId: number): PendingExec | undefined;
314
381
  classify(sessionId: string, execId: number): ContinuationClassification;
315
382
  claim(sessionId: string, execId: number): ContinuationClaim | ContinuationClassification;
316
- 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>;
317
386
  /** Find the live session awaiting one of the given exec ids. */
318
387
  findByExecIds(sessionId: string, execIds: number[]): CursorSession | undefined;
319
388
  /** Mark an exec id as resolved (its result has been delivered). */
@@ -342,6 +411,22 @@ export declare class SessionManager {
342
411
  dispose(): void;
343
412
  sweepHardDeadlines(): void;
344
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
+ */
345
430
  private refreshHardDeadline;
346
431
  private hardDeadlineExpired;
347
432
  private scheduleHardDeadline;
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
  }
@@ -395,11 +469,15 @@ 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;
398
475
  if (runId !== undefined)
399
476
  session.runId = runId;
400
477
  session.frames = next.frames()[Symbol.asyncIterator]();
401
478
  session.pendingFrameRead = undefined;
402
479
  session.pushbackFrame = undefined;
480
+ session.heldFrames = undefined;
403
481
  session.parallelStep = undefined;
404
482
  session.priorParallelStepCallIds = undefined;
405
483
  this.subscribeTerminal(session);
@@ -443,7 +521,10 @@ export class SessionManager {
443
521
  session.terminalUnsubscribe?.();
444
522
  session.terminalUnsubscribe = null;
445
523
  session.deferredTerminalReason = null;
446
- 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);
447
528
  const key = this.key(session.sessionId, id);
448
529
  this.byExecId.delete(key);
449
530
  if (this.isTerminalReason(reason))
@@ -452,6 +533,7 @@ export class SessionManager {
452
533
  session.pending.clear();
453
534
  session.pendingFrameRead = undefined;
454
535
  session.pushbackFrame = undefined;
536
+ session.heldFrames = undefined;
455
537
  session.parallelStep = undefined;
456
538
  session.priorParallelStepCallIds = undefined;
457
539
  session.pumpOwner = null;
@@ -492,6 +574,7 @@ export class SessionManager {
492
574
  for (const session of [...this.sessions])
493
575
  this.close(session, "process-disposed");
494
576
  this.byExecId.clear();
577
+ this.toolCallRoutes.clear();
495
578
  }
496
579
  sweepHardDeadlines() {
497
580
  for (const session of [...this.sessions]) {
@@ -507,22 +590,55 @@ export class SessionManager {
507
590
  const reason = event.kind === "remote-error" ? "remote-error" : "remote-clean-close";
508
591
  if (event.kind === "remote-error")
509
592
  session.closeError = event.error;
510
- if (session.pumpOwner !== null) {
593
+ if (session.pumpOwner !== null || session.hostInterrupted) {
511
594
  session.deferredTerminalReason = reason;
512
595
  return;
513
596
  }
514
597
  this.close(session, reason, event.kind === "remote-error" ? event.error : undefined);
515
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
+ */
516
623
  refreshHardDeadline(session, pending) {
517
624
  if (!session.openCodeSessionId)
518
625
  return pending.hardDeadlineAt;
626
+ const now = this.now();
627
+ const toolCapAt = this.activitySource.runningToolDeadline?.(session.openCodeSessionId, now, RUNNING_TOOL_CAP_MS);
519
628
  const activityAt = this.activitySource.lastActivityAt(session.openCodeSessionId);
520
- if (activityAt === undefined || activityAt <= pending.registeredAt)
521
- return pending.hardDeadlineAt;
522
- const renewedDeadline = activityAt + session.policy.hardCapMs;
523
- if (renewedDeadline > pending.hardDeadlineAt) {
524
- pending.hardDeadlineAt = renewedDeadline;
525
- 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
+ }
526
642
  }
527
643
  return pending.hardDeadlineAt;
528
644
  }
@@ -586,6 +702,9 @@ export async function readSessionFrame(session, timeoutMs) {
586
702
  session.pushbackFrame = undefined;
587
703
  return { done: false, value };
588
704
  }
705
+ const held = session.heldFrames?.shift();
706
+ if (held)
707
+ return { done: false, value: held };
589
708
  const pending = session.pendingFrameRead ??= session.frames.next();
590
709
  // A timed-out read can reject while no pump is consuming it. Preserve the
591
710
  // rejection for the next reader without an unhandled detached promise.
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;