cursor-opencode-provider 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/session.d.ts CHANGED
@@ -1,8 +1,26 @@
1
1
  import type { BidiStream } from "./transport/connect.js";
2
+ import { type CursorProviderError } from "./errors.js";
3
+ import { type SessionActivitySource } from "./activity.js";
2
4
  export type Frame = {
3
5
  flags: number;
4
6
  payload: Uint8Array;
5
7
  };
8
+ export type CursorContinuationOptions = {
9
+ semanticIdleMs?: number;
10
+ /** @deprecated Use semanticIdleMs. Kept as a strict alias for compatibility. */
11
+ softHealthMs?: number;
12
+ /** Pending-tool inactivity window, renewed by OpenCode session progress. */
13
+ hardCapMs?: number;
14
+ heartbeatMs?: number;
15
+ };
16
+ export type CursorContinuationPolicy = {
17
+ semanticIdleMs: number;
18
+ hardCapMs: number;
19
+ heartbeatMs: number;
20
+ };
21
+ export declare const DEFAULT_CONTINUATION_POLICY: Readonly<CursorContinuationPolicy>;
22
+ export declare function resolveContinuationPolicy(options: CursorContinuationOptions | undefined): CursorContinuationPolicy;
23
+ export type PendingExecState = "pending" | "claimed" | "delivered";
6
24
  /**
7
25
  * A held-open Run stream. Cursor drives the agentic loop server-side and
8
26
  * expects tool results on the SAME bidi stream. opencode, by contrast, owns
@@ -14,11 +32,16 @@ export type Frame = {
14
32
  export type PendingExec = {
15
33
  /** ExecClientMessage result field to reply with (matches the request variant). */
16
34
  resultField: string;
35
+ state: PendingExecState;
36
+ registeredAt: number;
37
+ hardDeadlineAt: number;
17
38
  /**
18
39
  * Resolved opencode tool name (read/write/grep/…). Used on continuation so
19
40
  * mcp_result can unwrap read envelopes even if the prompt omits toolName.
20
41
  */
21
42
  toolName?: string;
43
+ /** Original request fields required by a typed result message. */
44
+ resultMetadata?: Record<string, unknown>;
22
45
  /**
23
46
  * True when this pending entry was synthesized from a Cursor display-only
24
47
  * tool_call_* frame (no ExecServerMessage). Continuation must not write an
@@ -26,6 +49,43 @@ export type PendingExec = {
26
49
  */
27
50
  bridged?: boolean;
28
51
  };
52
+ 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";
53
+ export type SessionCloseReason = ContinuationTerminalReason | "ordinary-cleanup" | "turn-ended" | "initial-write-failed";
54
+ export type ContinuationClaim = {
55
+ session: CursorSession;
56
+ execId: number;
57
+ pending: PendingExec;
58
+ };
59
+ export type ContinuationClassification = {
60
+ kind: "deliverable";
61
+ session: CursorSession;
62
+ pending: PendingExec;
63
+ } | {
64
+ kind: "duplicate";
65
+ reason: "in-flight" | "delivered";
66
+ } | {
67
+ kind: "terminal";
68
+ reason: ContinuationTerminalReason;
69
+ } | {
70
+ kind: "missing";
71
+ reason: "missing-process-local-state";
72
+ };
73
+ export type DeliveryOutcome = {
74
+ kind: "delivered";
75
+ framesWritten: number;
76
+ } | {
77
+ kind: "duplicate";
78
+ reason: "in-flight" | "delivered";
79
+ framesWritten: 0;
80
+ } | {
81
+ kind: "terminal";
82
+ reason: ContinuationTerminalReason;
83
+ framesWritten: number;
84
+ } | {
85
+ kind: "missing";
86
+ reason: "missing-process-local-state";
87
+ framesWritten: 0;
88
+ };
29
89
  export type CursorSession = {
30
90
  /**
31
91
  * Stable per-Run-stream id (distinct from Cursor's own conversation_id).
@@ -36,8 +96,10 @@ export type CursorSession = {
36
96
  /**
37
97
  * Cursor conversation_id for this Run — used to store/echo
38
98
  * conversation_checkpoint_update (CLI parity).
39
- */
99
+ */
40
100
  conversationId: string;
101
+ /** OpenCode session whose own or descendant activity renews tool leases. */
102
+ openCodeSessionId?: string;
41
103
  stream: BidiStream;
42
104
  frames: AsyncIterator<Frame>;
43
105
  pending: Map<number, PendingExec>;
@@ -77,26 +139,62 @@ export type CursorSession = {
77
139
  * Prevents a late cancel/abort from a prior ReadableStream from destroying
78
140
  * the Run connection after tool results were delivered (pending cleared) but
79
141
  * Cursor is still generating.
80
- */
142
+ */
81
143
  pumpActive: boolean;
144
+ /** The active pull owner; stale cancel callbacks cannot affect a newer pump. */
145
+ pumpOwner: symbol | null;
82
146
  heartbeat: ReturnType<typeof setInterval> | null;
83
- expiresAt: number;
147
+ heartbeatCancel: (() => void) | null;
148
+ hardDeadlineTimer: ReturnType<typeof setTimeout> | null;
149
+ semanticDeadlineCancel: (() => void) | null;
150
+ terminalUnsubscribe: (() => void) | null;
151
+ deferredTerminalReason: "remote-clean-close" | "remote-error" | null;
152
+ policy: CursorContinuationPolicy;
153
+ createdAt: number;
154
+ lastInboundAt: number;
155
+ lastHeartbeatWriteAt: number;
156
+ semanticDeadlineAt: number;
157
+ closeError: CursorProviderError | null;
158
+ closed: boolean;
159
+ };
160
+ type SessionManagerOptions = {
161
+ now?: () => number;
162
+ setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
163
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
164
+ activitySource?: SessionActivitySource;
165
+ tombstoneTtlMs?: number;
166
+ tombstoneLimit?: number;
84
167
  };
85
168
  export declare class SessionManager {
86
169
  private byExecId;
87
- private readonly idleTimeoutMs;
88
- constructor(idleTimeoutMs?: number);
89
- touch(session: CursorSession): void;
170
+ private sessions;
171
+ private tombstones;
172
+ private readonly now;
173
+ private readonly setTimer;
174
+ private readonly clearTimer;
175
+ private readonly activitySource;
176
+ private readonly tombstoneTtlMs;
177
+ private readonly tombstoneLimit;
178
+ constructor(options?: SessionManagerOptions);
179
+ registerSession(session: CursorSession): void;
180
+ recordSemanticProgress(session: CursorSession, at?: number): void;
181
+ recordHeartbeatWrite(session: CursorSession): void;
90
182
  /** Register that `session` is awaiting a result for `execId`. */
91
- registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string, bridged?: boolean): void;
183
+ registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string, bridged?: boolean, resultMetadata?: Record<string, unknown>): void;
92
184
  /** The pending exec info for an id on a specific session, if still awaiting it. */
93
185
  pendingFor(sessionId: string, execId: number): PendingExec | undefined;
186
+ classify(sessionId: string, execId: number): ContinuationClassification;
187
+ claim(sessionId: string, execId: number): ContinuationClaim | ContinuationClassification;
188
+ deliverClaim(claim: ContinuationClaim, frames: readonly Uint8Array[]): DeliveryOutcome;
94
189
  /** Find the live session awaiting one of the given exec ids. */
95
190
  findByExecIds(sessionId: string, execIds: number[]): CursorSession | undefined;
96
191
  /** Mark an exec id as resolved (its result has been delivered). */
97
192
  resolve(sessionId: string, execId: number): void;
193
+ beginPump(session: CursorSession, owner: symbol): void;
194
+ isPumpOwner(session: CursorSession, owner: symbol): boolean;
195
+ endPump(session: CursorSession, owner: symbol): boolean;
98
196
  private key;
99
- close(session: CursorSession): void;
197
+ close(session: CursorSession, reason?: SessionCloseReason, error?: CursorProviderError): void;
100
198
  /**
101
199
  * Close only if nothing is awaiting a tool result AND no pull() is actively
102
200
  * pumping this session. OpenCode aborts each doStream after finishReason
@@ -107,5 +205,14 @@ export declare class SessionManager {
107
205
  */
108
206
  closeUnlessPending(session: CursorSession): boolean;
109
207
  dispose(): void;
208
+ sweepHardDeadlines(): void;
209
+ private onStreamTerminal;
210
+ private refreshHardDeadline;
211
+ private hardDeadlineExpired;
212
+ private scheduleHardDeadline;
213
+ private getTombstone;
214
+ private putTombstone;
215
+ private isTerminalReason;
110
216
  }
111
217
  export declare const sessionManager: SessionManager;
218
+ export {};
package/dist/session.js CHANGED
@@ -1,34 +1,229 @@
1
1
  import { trace } from "./debug.js";
2
+ import { CursorProtocolError } from "./errors.js";
3
+ import { sessionActivity } from "./activity.js";
4
+ export const DEFAULT_CONTINUATION_POLICY = {
5
+ semanticIdleMs: 120_000,
6
+ hardCapMs: 600_000,
7
+ heartbeatMs: 5_000,
8
+ };
9
+ const MAX_TIMER_MS = 2_147_483_647;
10
+ const DEFAULT_TOMBSTONE_TTL_MS = 15 * 60_000;
11
+ const DEFAULT_TOMBSTONE_LIMIT = 1_024;
12
+ function positiveInteger(name, value, fallback) {
13
+ const resolved = value === undefined ? fallback : value;
14
+ if (typeof resolved !== "number" ||
15
+ !Number.isSafeInteger(resolved) ||
16
+ resolved <= 0 ||
17
+ resolved > MAX_TIMER_MS) {
18
+ throw new CursorProtocolError(`Cursor continuation ${name} must be a positive integer no greater than ${MAX_TIMER_MS}`);
19
+ }
20
+ return resolved;
21
+ }
22
+ export function resolveContinuationPolicy(options) {
23
+ if (options !== undefined && (options === null || typeof options !== "object" || Array.isArray(options))) {
24
+ throw new CursorProtocolError("Cursor continuation options must be an object");
25
+ }
26
+ for (const key of Object.keys(options ?? {})) {
27
+ if (!["heartbeatMs", "semanticIdleMs", "softHealthMs", "hardCapMs"].includes(key)) {
28
+ throw new CursorProtocolError(`Unknown Cursor continuation option: ${key}`);
29
+ }
30
+ }
31
+ if (options?.semanticIdleMs !== undefined &&
32
+ options.softHealthMs !== undefined &&
33
+ options.semanticIdleMs !== options.softHealthMs) {
34
+ throw new CursorProtocolError("Cursor continuation semanticIdleMs and deprecated softHealthMs must match when both are set");
35
+ }
36
+ const heartbeatMs = positiveInteger("heartbeatMs", options?.heartbeatMs, DEFAULT_CONTINUATION_POLICY.heartbeatMs);
37
+ const semanticIdleMs = positiveInteger("semanticIdleMs", options?.semanticIdleMs ?? options?.softHealthMs, DEFAULT_CONTINUATION_POLICY.semanticIdleMs);
38
+ const hardCapMs = positiveInteger("hardCapMs", options?.hardCapMs, DEFAULT_CONTINUATION_POLICY.hardCapMs);
39
+ if (heartbeatMs >= semanticIdleMs) {
40
+ throw new CursorProtocolError("Cursor continuation heartbeatMs must be less than semanticIdleMs");
41
+ }
42
+ if (semanticIdleMs > hardCapMs) {
43
+ throw new CursorProtocolError("Cursor continuation semanticIdleMs must be no greater than hardCapMs");
44
+ }
45
+ return { heartbeatMs, semanticIdleMs, hardCapMs };
46
+ }
2
47
  export class SessionManager {
3
48
  // Composite key `${sessionId}:${execId}` → owning session. Composite keying
4
49
  // means two Run streams that both register an execId of 1 (Cursor resets
5
50
  // counters per stream) coexist instead of overwriting each other.
6
51
  byExecId = new Map();
7
- idleTimeoutMs;
8
- constructor(idleTimeoutMs = 300_000) {
9
- this.idleTimeoutMs = idleTimeoutMs;
52
+ sessions = new Set();
53
+ tombstones = new Map();
54
+ now;
55
+ setTimer;
56
+ clearTimer;
57
+ activitySource;
58
+ tombstoneTtlMs;
59
+ tombstoneLimit;
60
+ constructor(options = {}) {
61
+ this.now = options.now ?? Date.now;
62
+ this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
63
+ this.clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
64
+ this.activitySource = options.activitySource ?? sessionActivity;
65
+ this.tombstoneTtlMs = positiveInteger("tombstoneTtlMs", options.tombstoneTtlMs, DEFAULT_TOMBSTONE_TTL_MS);
66
+ this.tombstoneLimit = positiveInteger("tombstoneLimit", options.tombstoneLimit, DEFAULT_TOMBSTONE_LIMIT);
10
67
  }
11
- touch(session) {
12
- session.expiresAt = Date.now() + this.idleTimeoutMs;
68
+ registerSession(session) {
69
+ if (session.closed)
70
+ throw new CursorProtocolError("Cannot register a closed Cursor session");
71
+ if (this.sessions.has(session))
72
+ return;
73
+ session.closed ??= false;
74
+ session.closeError ??= null;
75
+ session.pumpOwner ??= null;
76
+ session.heartbeatCancel ??= null;
77
+ session.hardDeadlineTimer ??= null;
78
+ session.semanticDeadlineCancel ??= null;
79
+ session.terminalUnsubscribe ??= null;
80
+ session.deferredTerminalReason ??= null;
81
+ session.policy ??= { ...DEFAULT_CONTINUATION_POLICY };
82
+ session.createdAt ??= this.now();
83
+ session.lastInboundAt ??= this.now();
84
+ session.lastHeartbeatWriteAt ??= this.now();
85
+ session.semanticDeadlineAt ??= this.now() + session.policy.semanticIdleMs;
86
+ this.sessions.add(session);
87
+ const unsubscribe = session.stream.onTerminal?.((event) => this.onStreamTerminal(session, event)) ?? (() => { });
88
+ if (session.closed)
89
+ unsubscribe();
90
+ else
91
+ session.terminalUnsubscribe = unsubscribe;
92
+ }
93
+ recordSemanticProgress(session, at) {
94
+ if (session.closed)
95
+ return;
96
+ const now = at ?? this.now();
97
+ session.lastInboundAt = now;
98
+ session.semanticDeadlineAt = now + session.policy.semanticIdleMs;
99
+ }
100
+ recordHeartbeatWrite(session) {
101
+ if (!session.closed)
102
+ session.lastHeartbeatWriteAt = this.now();
13
103
  }
14
104
  /** Register that `session` is awaiting a result for `execId`. */
15
- registerPending(execId, session, resultField, toolName, bridged = false) {
16
- session.pending.set(execId, { resultField, toolName, bridged });
17
- this.byExecId.set(this.key(session.sessionId, execId), session);
18
- this.touch(session);
105
+ registerPending(execId, session, resultField, toolName, bridged = false, resultMetadata) {
106
+ this.registerSession(session);
107
+ if (session.closed)
108
+ throw new CursorProtocolError("Cannot register a pending exec on a closed Cursor session");
109
+ const now = this.now();
110
+ const key = this.key(session.sessionId, execId);
111
+ this.tombstones.delete(key);
112
+ session.pending.set(execId, {
113
+ resultField,
114
+ toolName,
115
+ bridged,
116
+ resultMetadata,
117
+ state: "pending",
118
+ registeredAt: now,
119
+ hardDeadlineAt: now + session.policy.hardCapMs,
120
+ });
121
+ this.byExecId.set(key, session);
122
+ this.scheduleHardDeadline(session);
19
123
  }
20
124
  /** The pending exec info for an id on a specific session, if still awaiting it. */
21
125
  pendingFor(sessionId, execId) {
22
126
  return this.byExecId.get(this.key(sessionId, execId))?.pending.get(execId);
23
127
  }
128
+ classify(sessionId, execId) {
129
+ const key = this.key(sessionId, execId);
130
+ const session = this.byExecId.get(key);
131
+ if (session) {
132
+ const pending = session.pending.get(execId);
133
+ const legacyExpiresAt = session.expiresAt;
134
+ if (!pending ||
135
+ session.closed ||
136
+ session.stream.isClosed() ||
137
+ (typeof legacyExpiresAt === "number" && this.now() >= legacyExpiresAt)) {
138
+ this.byExecId.delete(key);
139
+ if (!session.closed)
140
+ this.close(session, "remote-clean-close");
141
+ }
142
+ else if (this.hardDeadlineExpired(session, pending)) {
143
+ this.close(session, "hard-cap-expired");
144
+ }
145
+ else if (pending.state === "pending") {
146
+ return { kind: "deliverable", session, pending };
147
+ }
148
+ else if (pending.state === "claimed") {
149
+ return { kind: "duplicate", reason: "in-flight" };
150
+ }
151
+ else {
152
+ return { kind: "duplicate", reason: "delivered" };
153
+ }
154
+ }
155
+ const tombstone = this.getTombstone(key);
156
+ if (!tombstone)
157
+ return { kind: "missing", reason: "missing-process-local-state" };
158
+ if (tombstone.reason === "delivered")
159
+ return { kind: "duplicate", reason: "delivered" };
160
+ return { kind: "terminal", reason: tombstone.reason };
161
+ }
162
+ claim(sessionId, execId) {
163
+ const classification = this.classify(sessionId, execId);
164
+ if (classification.kind !== "deliverable")
165
+ return classification;
166
+ classification.pending.state = "claimed";
167
+ return {
168
+ session: classification.session,
169
+ execId,
170
+ pending: classification.pending,
171
+ };
172
+ }
173
+ deliverClaim(claim, frames) {
174
+ const { session, execId, pending } = claim;
175
+ const key = this.key(session.sessionId, execId);
176
+ if (session.closed ||
177
+ this.byExecId.get(key) !== session ||
178
+ session.pending.get(execId) !== pending) {
179
+ const current = this.classify(session.sessionId, execId);
180
+ if (current.kind === "duplicate")
181
+ return { ...current, framesWritten: 0 };
182
+ if (current.kind === "terminal")
183
+ return { ...current, framesWritten: 0 };
184
+ return { kind: "missing", reason: "missing-process-local-state", framesWritten: 0 };
185
+ }
186
+ if (pending.state !== "claimed") {
187
+ return {
188
+ kind: "duplicate",
189
+ reason: pending.state === "delivered" ? "delivered" : "in-flight",
190
+ framesWritten: 0,
191
+ };
192
+ }
193
+ if (this.hardDeadlineExpired(session, pending)) {
194
+ this.close(session, "hard-cap-expired");
195
+ return { kind: "terminal", reason: "hard-cap-expired", framesWritten: 0 };
196
+ }
197
+ let framesWritten = 0;
198
+ try {
199
+ if (!pending.bridged && frames.length === 0) {
200
+ throw new CursorProtocolError("No result frames were produced");
201
+ }
202
+ for (const frame of frames) {
203
+ session.stream.write(frame);
204
+ framesWritten++;
205
+ }
206
+ }
207
+ catch {
208
+ const reason = framesWritten === 0 ? "result-write-failed" : "ambiguous-partial-write";
209
+ this.close(session, reason);
210
+ return { kind: "terminal", reason, framesWritten };
211
+ }
212
+ pending.state = "delivered";
213
+ this.putTombstone(key, "delivered");
214
+ session.pending.delete(execId);
215
+ this.byExecId.delete(key);
216
+ if (session.pending.size === 0)
217
+ this.recordSemanticProgress(session);
218
+ this.scheduleHardDeadline(session);
219
+ return { kind: "delivered", framesWritten };
220
+ }
24
221
  /** Find the live session awaiting one of the given exec ids. */
25
222
  findByExecIds(sessionId, execIds) {
26
223
  for (const id of execIds) {
27
- const s = this.byExecId.get(this.key(sessionId, id));
28
- if (s && Date.now() < s.expiresAt)
29
- return s;
30
- if (s)
31
- this.close(s);
224
+ const classification = this.classify(sessionId, id);
225
+ if (classification.kind === "deliverable")
226
+ return classification.session;
32
227
  }
33
228
  return undefined;
34
229
  }
@@ -36,22 +231,74 @@ export class SessionManager {
36
231
  resolve(sessionId, execId) {
37
232
  const k = this.key(sessionId, execId);
38
233
  const s = this.byExecId.get(k);
39
- if (s)
234
+ if (s) {
40
235
  s.pending.delete(execId);
236
+ this.putTombstone(k, "delivered");
237
+ this.scheduleHardDeadline(s);
238
+ }
41
239
  this.byExecId.delete(k);
42
240
  }
241
+ beginPump(session, owner) {
242
+ this.registerSession(session);
243
+ if (session.pumpOwner && session.pumpOwner !== owner) {
244
+ throw new CursorProtocolError("Cursor session already has an active pump");
245
+ }
246
+ session.pumpOwner = owner;
247
+ session.pumpActive = true;
248
+ }
249
+ isPumpOwner(session, owner) {
250
+ return !session.closed && session.pumpOwner === owner;
251
+ }
252
+ endPump(session, owner) {
253
+ if (session.pumpOwner !== owner)
254
+ return false;
255
+ session.pumpOwner = null;
256
+ session.pumpActive = false;
257
+ const deferred = session.deferredTerminalReason;
258
+ session.deferredTerminalReason = null;
259
+ if (deferred)
260
+ this.close(session, deferred);
261
+ return true;
262
+ }
43
263
  key(sessionId, execId) {
44
264
  return `${sessionId}:${execId}`;
45
265
  }
46
- close(session) {
47
- trace(`sessionManager.close: pending=[${[...session.pending.keys()].join(",")}] blobs=${session.blobs.size}`);
48
- if (session.heartbeat)
266
+ close(session, reason = "ordinary-cleanup", error) {
267
+ if (session.closed)
268
+ return;
269
+ session.closed = true;
270
+ session.closeError = error ?? session.closeError ?? null;
271
+ trace(`sessionManager.close: reason=${reason} pendingCount=${session.pending.size} blobs=${session.blobs.size}`);
272
+ if (session.heartbeatCancel)
273
+ session.heartbeatCancel();
274
+ else if (session.heartbeat)
49
275
  clearInterval(session.heartbeat);
50
276
  session.heartbeat = null;
51
- session.stream.destroy();
52
- for (const id of session.pending.keys())
53
- this.byExecId.delete(this.key(session.sessionId, id));
277
+ session.heartbeatCancel = null;
278
+ if (session.hardDeadlineTimer)
279
+ this.clearTimer(session.hardDeadlineTimer);
280
+ session.hardDeadlineTimer = null;
281
+ session.semanticDeadlineCancel?.();
282
+ session.semanticDeadlineCancel = null;
283
+ session.terminalUnsubscribe?.();
284
+ session.terminalUnsubscribe = null;
285
+ session.deferredTerminalReason = null;
286
+ for (const id of session.pending.keys()) {
287
+ const key = this.key(session.sessionId, id);
288
+ this.byExecId.delete(key);
289
+ if (this.isTerminalReason(reason))
290
+ this.putTombstone(key, reason);
291
+ }
54
292
  session.pending.clear();
293
+ session.pumpOwner = null;
294
+ session.pumpActive = false;
295
+ session.displayToolCalls?.clear();
296
+ session.blobs?.clear();
297
+ this.sessions.delete(session);
298
+ try {
299
+ session.stream.destroy();
300
+ }
301
+ catch { /* already closed */ }
55
302
  }
56
303
  /**
57
304
  * Close only if nothing is awaiting a tool result AND no pull() is actively
@@ -62,23 +309,107 @@ export class SessionManager {
62
309
  * Returns true if the session was closed.
63
310
  */
64
311
  closeUnlessPending(session) {
65
- if (session.pending.size > 0 || session.pumpActive) {
66
- trace(`sessionManager.closeUnlessPending: KEEP open pending=[${[...session.pending.keys()].join(",")}] pumpActive=${session.pumpActive}`);
312
+ if (session.closed)
313
+ return true;
314
+ const pumpActive = session.pumpOwner != null || session.pumpActive;
315
+ if (session.pending.size > 0 || pumpActive) {
316
+ trace(`sessionManager.closeUnlessPending: KEEP open pendingCount=${session.pending.size} pumpActive=${pumpActive}`);
67
317
  return false;
68
318
  }
69
- this.close(session);
319
+ this.close(session, "ordinary-cleanup");
70
320
  return true;
71
321
  }
72
322
  dispose() {
73
- const seen = new Set();
74
- for (const s of this.byExecId.values()) {
75
- if (seen.has(s))
76
- continue;
77
- seen.add(s);
78
- this.close(s);
79
- }
323
+ for (const session of [...this.sessions])
324
+ this.close(session, "process-disposed");
80
325
  this.byExecId.clear();
81
326
  }
327
+ sweepHardDeadlines() {
328
+ for (const session of [...this.sessions]) {
329
+ if (!session.closed &&
330
+ [...session.pending.values()].some((pending) => this.hardDeadlineExpired(session, pending))) {
331
+ this.close(session, "hard-cap-expired");
332
+ }
333
+ }
334
+ }
335
+ onStreamTerminal(session, event) {
336
+ if (event.kind === "local-close" || session.closed)
337
+ return;
338
+ const reason = event.kind === "remote-error" ? "remote-error" : "remote-clean-close";
339
+ if (event.kind === "remote-error")
340
+ session.closeError = event.error;
341
+ if (session.pumpOwner !== null) {
342
+ session.deferredTerminalReason = reason;
343
+ return;
344
+ }
345
+ this.close(session, reason, event.kind === "remote-error" ? event.error : undefined);
346
+ }
347
+ refreshHardDeadline(session, pending) {
348
+ if (!session.openCodeSessionId)
349
+ return pending.hardDeadlineAt;
350
+ const activityAt = this.activitySource.lastActivityAt(session.openCodeSessionId);
351
+ if (activityAt === undefined || activityAt <= pending.registeredAt)
352
+ return pending.hardDeadlineAt;
353
+ const renewedDeadline = activityAt + session.policy.hardCapMs;
354
+ if (renewedDeadline > pending.hardDeadlineAt) {
355
+ pending.hardDeadlineAt = renewedDeadline;
356
+ trace("continuation lease renewed from OpenCode session activity");
357
+ }
358
+ return pending.hardDeadlineAt;
359
+ }
360
+ hardDeadlineExpired(session, pending) {
361
+ return this.now() >= this.refreshHardDeadline(session, pending);
362
+ }
363
+ scheduleHardDeadline(session) {
364
+ if (session.hardDeadlineTimer)
365
+ this.clearTimer(session.hardDeadlineTimer);
366
+ session.hardDeadlineTimer = null;
367
+ if (session.closed || session.pending.size === 0)
368
+ return;
369
+ const earliest = Math.min(...[...session.pending.values()].map((pending) => this.refreshHardDeadline(session, pending)));
370
+ const delayMs = Math.max(0, earliest - this.now());
371
+ const timer = this.setTimer(() => {
372
+ session.hardDeadlineTimer = null;
373
+ if (session.closed)
374
+ return;
375
+ if ([...session.pending.values()].some((pending) => this.hardDeadlineExpired(session, pending))) {
376
+ this.close(session, "hard-cap-expired");
377
+ }
378
+ else {
379
+ this.scheduleHardDeadline(session);
380
+ }
381
+ }, delayMs);
382
+ session.hardDeadlineTimer = timer;
383
+ const unref = timer.unref;
384
+ if (typeof unref === "function")
385
+ unref.call(timer);
386
+ }
387
+ getTombstone(key) {
388
+ const tombstone = this.tombstones.get(key);
389
+ if (!tombstone)
390
+ return undefined;
391
+ if (this.now() >= tombstone.expiresAt) {
392
+ this.tombstones.delete(key);
393
+ return undefined;
394
+ }
395
+ return tombstone;
396
+ }
397
+ putTombstone(key, reason) {
398
+ this.tombstones.delete(key);
399
+ this.tombstones.set(key, {
400
+ reason,
401
+ expiresAt: this.now() + this.tombstoneTtlMs,
402
+ });
403
+ while (this.tombstones.size > this.tombstoneLimit) {
404
+ const oldest = this.tombstones.keys().next().value;
405
+ if (oldest === undefined)
406
+ break;
407
+ this.tombstones.delete(oldest);
408
+ }
409
+ }
410
+ isTerminalReason(reason) {
411
+ return !["ordinary-cleanup", "turn-ended", "initial-write-failed"].includes(reason);
412
+ }
82
413
  }
83
414
  export const sessionManager = new SessionManager();
84
415
  function installProcessCleanup() {
@@ -0,0 +1,57 @@
1
+ /** Cursor agent.v1 TimeoutBehavior enum values. */
2
+ export declare const CURSOR_TIMEOUT_CANCEL = 1;
3
+ export declare const CURSOR_TIMEOUT_BACKGROUND = 2;
4
+ export type CursorShellPolicy = {
5
+ command: string;
6
+ workingDirectory: string;
7
+ timeoutMs: number;
8
+ timeoutBehavior: number;
9
+ hardTimeoutMs?: number;
10
+ };
11
+ export type CursorShellOutcome = {
12
+ kind: "exit";
13
+ code: number;
14
+ } | {
15
+ kind: "timeout";
16
+ timeoutMs: number;
17
+ } | {
18
+ kind: "backgrounded";
19
+ shellId: number;
20
+ pid: number;
21
+ command: string;
22
+ workingDirectory: string;
23
+ msToWait: number;
24
+ reason: 1;
25
+ };
26
+ export declare function shellPolicyFromMetadata(metadata: Record<string, unknown> | undefined): CursorShellPolicy | undefined;
27
+ /** Register a Cursor shell request before OpenCode executes its emitted tool call. */
28
+ export declare function registerCursorShellCall(toolCallId: string, metadata: Record<string, unknown> | undefined): void;
29
+ /**
30
+ * F11 / soft-background helper.
31
+ *
32
+ * Run a Cursor soft-background command for its foreground window, then leave
33
+ * it detached (`nohup`) if still alive. The sentinel is removed by the after
34
+ * hook before OpenCode stores/renders the result.
35
+ *
36
+ * This approximates Cursor's TIMEOUT_BACKGROUND semantics through OpenCode's
37
+ * foreground-only bash tool. Residual: after OpenCode returns, the child (and
38
+ * optional hard-timeout watchdog) may still be running; this provider does not
39
+ * reap leftover processes — cleanup is left to the user / OS.
40
+ */
41
+ export declare function buildSoftBackgroundCommand(policy: CursorShellPolicy): string;
42
+ /** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
43
+ export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
44
+ /** Restore the model-facing command in OpenCode's completed tool title. */
45
+ export declare function cursorShellOriginalCommand(toolCallId: string): string | undefined;
46
+ /**
47
+ * Capture Bash completion in the classic plugin's after hook. Returns the
48
+ * sanitized output that OpenCode should store and render.
49
+ */
50
+ export declare function captureCursorShellResult(toolCallId: string, output: string, metadata?: Record<string, unknown>): string;
51
+ /** Consume the structured result, with an inline fallback when no plugin hook ran. */
52
+ export declare function consumeCursorShellResult(toolCallId: string, output: string): {
53
+ output: string;
54
+ outcome?: CursorShellOutcome;
55
+ };
56
+ /** Test/process cleanup. */
57
+ export declare function resetCursorShellCalls(): void;