cursor-opencode-provider 0.2.2 → 0.2.4
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/README.md +27 -12
- package/dist/activity.d.ts +15 -0
- package/dist/activity.js +76 -0
- package/dist/context/build.js +0 -7
- package/dist/errors.d.ts +59 -0
- package/dist/errors.js +199 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +4 -0
- package/dist/language-model.d.ts +47 -4
- package/dist/language-model.js +638 -184
- package/dist/models.d.ts +7 -0
- package/dist/models.js +259 -79
- package/dist/plugin.js +35 -2
- package/dist/protocol/exec-variants.d.ts +24 -0
- package/dist/protocol/exec-variants.js +55 -0
- package/dist/protocol/messages.js +137 -24
- package/dist/protocol/request.d.ts +0 -2
- package/dist/protocol/request.js +0 -8
- package/dist/protocol/struct.js +1 -1
- package/dist/protocol/tool-call-bridge.js +2 -0
- package/dist/protocol/tools.d.ts +14 -1
- package/dist/protocol/tools.js +312 -27
- package/dist/session.d.ts +115 -8
- package/dist/session.js +362 -31
- package/dist/shell-timeout.d.ts +50 -0
- package/dist/shell-timeout.js +179 -0
- package/dist/transport/connect.d.ts +37 -1
- package/dist/transport/connect.js +679 -115
- package/package.json +6 -1
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
session.
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
28
|
-
if (
|
|
29
|
-
return
|
|
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
|
-
|
|
48
|
-
|
|
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.
|
|
52
|
-
|
|
53
|
-
this.
|
|
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.
|
|
66
|
-
|
|
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
|
|
74
|
-
|
|
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,50 @@
|
|
|
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
|
+
* Run a Cursor soft-background command for its foreground window, then leave
|
|
31
|
+
* it detached if still alive. The sentinel is removed by the after hook before
|
|
32
|
+
* OpenCode stores/renders the result.
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildSoftBackgroundCommand(policy: CursorShellPolicy): string;
|
|
35
|
+
/** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
|
|
36
|
+
export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
|
|
37
|
+
/** Restore the model-facing command in OpenCode's completed tool title. */
|
|
38
|
+
export declare function cursorShellOriginalCommand(toolCallId: string): string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Capture Bash completion in the classic plugin's after hook. Returns the
|
|
41
|
+
* sanitized output that OpenCode should store and render.
|
|
42
|
+
*/
|
|
43
|
+
export declare function captureCursorShellResult(toolCallId: string, output: string, metadata?: Record<string, unknown>): string;
|
|
44
|
+
/** Consume the structured result, with an inline fallback when no plugin hook ran. */
|
|
45
|
+
export declare function consumeCursorShellResult(toolCallId: string, output: string): {
|
|
46
|
+
output: string;
|
|
47
|
+
outcome?: CursorShellOutcome;
|
|
48
|
+
};
|
|
49
|
+
/** Test/process cleanup. */
|
|
50
|
+
export declare function resetCursorShellCalls(): void;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** Cursor agent.v1 TimeoutBehavior enum values. */
|
|
2
|
+
export const CURSOR_TIMEOUT_CANCEL = 1;
|
|
3
|
+
export const CURSOR_TIMEOUT_BACKGROUND = 2;
|
|
4
|
+
const MAX_TRACKED_SHELL_CALLS = 512;
|
|
5
|
+
const OPENCODE_TIMEOUT_GRACE_MS = 15_000;
|
|
6
|
+
const POLL_INTERVAL_MS = 100;
|
|
7
|
+
const BACKGROUND_MARKER = "__CURSOR_SHELL_BACKGROUND__";
|
|
8
|
+
const EXIT_MARKER = "__CURSOR_SHELL_EXIT__";
|
|
9
|
+
const TIMEOUT_MARKER = "__CURSOR_SHELL_TIMEOUT__";
|
|
10
|
+
const policies = new Map();
|
|
11
|
+
const outcomes = new Map();
|
|
12
|
+
function remember(map, key, value) {
|
|
13
|
+
map.delete(key);
|
|
14
|
+
map.set(key, value);
|
|
15
|
+
while (map.size > MAX_TRACKED_SHELL_CALLS) {
|
|
16
|
+
const oldest = map.keys().next().value;
|
|
17
|
+
if (!oldest)
|
|
18
|
+
break;
|
|
19
|
+
map.delete(oldest);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function finiteNonNegative(value) {
|
|
23
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
24
|
+
if (!Number.isFinite(n) || n < 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
return Math.floor(n);
|
|
27
|
+
}
|
|
28
|
+
export function shellPolicyFromMetadata(metadata) {
|
|
29
|
+
if (!metadata || metadata.shell_stream !== true)
|
|
30
|
+
return undefined;
|
|
31
|
+
const timeoutMs = finiteNonNegative(metadata.timeout_ms) ?? 30_000;
|
|
32
|
+
const timeoutBehavior = finiteNonNegative(metadata.timeout_behavior) ?? 0;
|
|
33
|
+
const hardTimeoutMs = finiteNonNegative(metadata.hard_timeout_ms);
|
|
34
|
+
return {
|
|
35
|
+
command: typeof metadata.command === "string" ? metadata.command : "",
|
|
36
|
+
workingDirectory: typeof metadata.working_directory === "string" ? metadata.working_directory : "",
|
|
37
|
+
timeoutMs,
|
|
38
|
+
timeoutBehavior,
|
|
39
|
+
...(hardTimeoutMs !== undefined && hardTimeoutMs > 0 ? { hardTimeoutMs } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Register a Cursor shell request before OpenCode executes its emitted tool call. */
|
|
43
|
+
export function registerCursorShellCall(toolCallId, metadata) {
|
|
44
|
+
const policy = shellPolicyFromMetadata(metadata);
|
|
45
|
+
if (!policy || !toolCallId.startsWith("cursor_"))
|
|
46
|
+
return;
|
|
47
|
+
remember(policies, toolCallId, policy);
|
|
48
|
+
}
|
|
49
|
+
function shellQuote(value) {
|
|
50
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Run a Cursor soft-background command for its foreground window, then leave
|
|
54
|
+
* it detached if still alive. The sentinel is removed by the after hook before
|
|
55
|
+
* OpenCode stores/renders the result.
|
|
56
|
+
*/
|
|
57
|
+
export function buildSoftBackgroundCommand(policy) {
|
|
58
|
+
const polls = Math.ceil(policy.timeoutMs / POLL_INTERVAL_MS);
|
|
59
|
+
const hardPolls = policy.hardTimeoutMs !== undefined
|
|
60
|
+
? Math.max(1, Math.ceil(policy.hardTimeoutMs / POLL_INTERVAL_MS))
|
|
61
|
+
: undefined;
|
|
62
|
+
const lines = [
|
|
63
|
+
'cursor_shell_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-shell.XXXXXX")" || exit 1',
|
|
64
|
+
`nohup sh -c ${shellQuote(policy.command)} >"$cursor_shell_log" 2>&1 </dev/null &`,
|
|
65
|
+
"cursor_shell_pid=$!",
|
|
66
|
+
];
|
|
67
|
+
if (hardPolls !== undefined) {
|
|
68
|
+
lines.push('cursor_shell_status="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-shell-status.XXXXXX")" || exit 1', `nohup sh -c 'cursor_hard_poll=0; while [ "$cursor_hard_poll" -lt "$1" ] && kill -0 "$2" 2>/dev/null; do sleep ${POLL_INTERVAL_MS / 1000}; cursor_hard_poll=$((cursor_hard_poll + 1)); done; if kill -0 "$2" 2>/dev/null; then printf timeout >"$3"; kill -TERM "$2" 2>/dev/null; sleep 3; kill -KILL "$2" 2>/dev/null; fi' cursor-shell-watchdog ${hardPolls} "$cursor_shell_pid" "$cursor_shell_status" >/dev/null 2>&1 </dev/null &`, "cursor_shell_watchdog_pid=$!");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
lines.push('cursor_shell_status=""', 'cursor_shell_watchdog_pid=""');
|
|
72
|
+
}
|
|
73
|
+
lines.push("cursor_shell_poll=0", `while [ "$cursor_shell_poll" -lt ${polls} ] && kill -0 "$cursor_shell_pid" 2>/dev/null; do`, ` sleep ${POLL_INTERVAL_MS / 1000}`, " cursor_shell_poll=$((cursor_shell_poll + 1))", "done", 'if kill -0 "$cursor_shell_pid" 2>/dev/null; then', ' cat "$cursor_shell_log"', ` printf '\n${BACKGROUND_MARKER}%s:%s\n' "$cursor_shell_pid" "$cursor_shell_log"`, " exit 0", "fi", 'wait "$cursor_shell_pid"', "cursor_shell_code=$?", 'cat "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ] && [ "$(cat "$cursor_shell_status" 2>/dev/null)" = timeout ]; then', ` printf '\n${TIMEOUT_MARKER}%s\n' ${policy.hardTimeoutMs ?? policy.timeoutMs}`, "else", ' if [ -n "$cursor_shell_watchdog_pid" ]; then kill "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', ` printf '\n${EXIT_MARKER}%s\n' "$cursor_shell_code"`, "fi", 'rm -f -- "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ]; then rm -f -- "$cursor_shell_status"; fi');
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
|
76
|
+
/** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
|
|
77
|
+
export function prepareCursorShellArgs(toolCallId, args) {
|
|
78
|
+
const policy = policies.get(toolCallId);
|
|
79
|
+
if (!policy || policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
|
|
80
|
+
return;
|
|
81
|
+
args.command = buildSoftBackgroundCommand(policy);
|
|
82
|
+
// The wrapper returns just after Cursor's foreground window. OpenCode's own
|
|
83
|
+
// timeout is only an outer safety net and must not win the race.
|
|
84
|
+
args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
|
|
85
|
+
}
|
|
86
|
+
/** Restore the model-facing command in OpenCode's completed tool title. */
|
|
87
|
+
export function cursorShellOriginalCommand(toolCallId) {
|
|
88
|
+
return policies.get(toolCallId)?.command || undefined;
|
|
89
|
+
}
|
|
90
|
+
function withoutMarker(output, index) {
|
|
91
|
+
let clean = output.slice(0, index).replace(/[\t ]+$/gm, "").replace(/\n{2,}$/, "\n");
|
|
92
|
+
if (clean.trim() === "" || clean.trim() === "(no output)")
|
|
93
|
+
clean = "";
|
|
94
|
+
return clean;
|
|
95
|
+
}
|
|
96
|
+
function parseOpenCodeTimeout(output) {
|
|
97
|
+
const re = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\.[\s\S]*?<\/shell_metadata>\s*$/;
|
|
98
|
+
const match = re.exec(output);
|
|
99
|
+
if (!match || match.index === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
return { output: withoutMarker(output, match.index), timeoutMs: Number(match[1]) };
|
|
102
|
+
}
|
|
103
|
+
function parseWrapperOutcome(output, policy) {
|
|
104
|
+
const background = new RegExp(`${BACKGROUND_MARKER}(\\d+):([^\\r\\n]+)\\s*$`).exec(output);
|
|
105
|
+
if (background?.index !== undefined) {
|
|
106
|
+
const pid = Number(background[1]);
|
|
107
|
+
if (Number.isSafeInteger(pid) && pid > 0 && pid <= 0xffff_ffff) {
|
|
108
|
+
return {
|
|
109
|
+
output: withoutMarker(output, background.index),
|
|
110
|
+
outcome: {
|
|
111
|
+
kind: "backgrounded",
|
|
112
|
+
shellId: pid,
|
|
113
|
+
pid,
|
|
114
|
+
command: policy?.command ?? "",
|
|
115
|
+
workingDirectory: policy?.workingDirectory ?? "",
|
|
116
|
+
msToWait: policy?.timeoutMs ?? 0,
|
|
117
|
+
reason: 1,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const timeout = new RegExp(`${TIMEOUT_MARKER}(\\d+)\\s*$`).exec(output);
|
|
123
|
+
if (timeout?.index !== undefined) {
|
|
124
|
+
return {
|
|
125
|
+
output: withoutMarker(output, timeout.index),
|
|
126
|
+
outcome: { kind: "timeout", timeoutMs: Number(timeout[1]) },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const exit = new RegExp(`${EXIT_MARKER}(-?\\d+)\\s*$`).exec(output);
|
|
130
|
+
if (exit?.index !== undefined) {
|
|
131
|
+
return {
|
|
132
|
+
output: withoutMarker(output, exit.index),
|
|
133
|
+
outcome: { kind: "exit", code: Number(exit[1]) },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Capture Bash completion in the classic plugin's after hook. Returns the
|
|
140
|
+
* sanitized output that OpenCode should store and render.
|
|
141
|
+
*/
|
|
142
|
+
export function captureCursorShellResult(toolCallId, output, metadata) {
|
|
143
|
+
if (!toolCallId.startsWith("cursor_"))
|
|
144
|
+
return output;
|
|
145
|
+
const policy = policies.get(toolCallId);
|
|
146
|
+
// Private wrapper sentinels are meaningful only for calls we transformed.
|
|
147
|
+
// A normal foreground command is allowed to print the same text verbatim.
|
|
148
|
+
const wrapper = policy?.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND
|
|
149
|
+
? parseWrapperOutcome(output, policy)
|
|
150
|
+
: undefined;
|
|
151
|
+
if (wrapper) {
|
|
152
|
+
remember(outcomes, toolCallId, wrapper.outcome);
|
|
153
|
+
return wrapper.output;
|
|
154
|
+
}
|
|
155
|
+
const timeout = parseOpenCodeTimeout(output);
|
|
156
|
+
if (timeout) {
|
|
157
|
+
remember(outcomes, toolCallId, { kind: "timeout", timeoutMs: timeout.timeoutMs });
|
|
158
|
+
return timeout.output;
|
|
159
|
+
}
|
|
160
|
+
const exitCode = finiteNonNegative(metadata?.exit);
|
|
161
|
+
if (exitCode !== undefined)
|
|
162
|
+
remember(outcomes, toolCallId, { kind: "exit", code: exitCode });
|
|
163
|
+
return output;
|
|
164
|
+
}
|
|
165
|
+
/** Consume the structured result, with an inline fallback when no plugin hook ran. */
|
|
166
|
+
export function consumeCursorShellResult(toolCallId, output) {
|
|
167
|
+
let clean = output;
|
|
168
|
+
if (!outcomes.has(toolCallId))
|
|
169
|
+
clean = captureCursorShellResult(toolCallId, output);
|
|
170
|
+
const outcome = outcomes.get(toolCallId);
|
|
171
|
+
outcomes.delete(toolCallId);
|
|
172
|
+
policies.delete(toolCallId);
|
|
173
|
+
return { output: clean, outcome };
|
|
174
|
+
}
|
|
175
|
+
/** Test/process cleanup. */
|
|
176
|
+
export function resetCursorShellCalls() {
|
|
177
|
+
policies.clear();
|
|
178
|
+
outcomes.clear();
|
|
179
|
+
}
|