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/README.md +36 -17
- package/SECURITY.md +42 -0
- package/dist/activity.d.ts +15 -0
- package/dist/activity.js +76 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +19 -13
- package/dist/context/build.js +0 -7
- package/dist/context/rules.d.ts +4 -0
- package/dist/context/rules.js +53 -17
- package/dist/debug.d.ts +9 -0
- package/dist/debug.js +44 -4
- 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 +54 -7
- package/dist/language-model.js +730 -197
- package/dist/models.d.ts +7 -0
- package/dist/models.js +259 -79
- package/dist/plugin.js +38 -11
- package/dist/protocol/exec-variants.d.ts +24 -0
- package/dist/protocol/exec-variants.js +55 -0
- package/dist/protocol/interactions.d.ts +8 -1
- package/dist/protocol/interactions.js +15 -2
- package/dist/protocol/messages.js +109 -26
- package/dist/protocol/request.d.ts +2 -4
- package/dist/protocol/request.js +0 -8
- package/dist/protocol/struct.js +1 -1
- package/dist/protocol/tool-call-bridge.js +13 -1
- package/dist/protocol/tools.d.ts +8 -1
- package/dist/protocol/tools.js +384 -39
- package/dist/session.d.ts +115 -8
- package/dist/session.js +362 -31
- package/dist/shell-timeout.d.ts +57 -0
- package/dist/shell-timeout.js +186 -0
- package/dist/transport/connect.d.ts +37 -1
- package/dist/transport/connect.js +679 -115
- package/package.json +8 -2
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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,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;
|