cursor-opencode-provider 0.8.0 → 0.8.1
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/CHANGELOG.md +79 -0
- package/README.md +28 -9
- package/SECURITY.md +43 -7
- package/dist/context/build.d.ts +22 -4
- package/dist/context/build.js +33 -5
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/frozen.js +6 -2
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +44 -17
- package/dist/context/paths.js +65 -18
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +119 -28
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/language-model.d.ts +45 -17
- package/dist/language-model.js +925 -292
- package/dist/model-config.d.ts +9 -0
- package/dist/model-config.js +28 -1
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +48 -40
- package/dist/plugin.js +69 -18
- package/dist/pricing-data.d.ts +14 -1
- package/dist/pricing-data.js +14 -1
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +74 -14
- package/dist/protocol/request.d.ts +5 -0
- package/dist/protocol/request.js +2 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +4 -0
- package/dist/protocol/tool-call-bridge.js +14 -0
- package/dist/protocol/tools.d.ts +16 -1
- package/dist/protocol/tools.js +113 -11
- package/dist/replay-safety.d.ts +6 -0
- package/dist/replay-safety.js +56 -6
- package/dist/session.d.ts +35 -1
- package/dist/session.js +45 -1
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +2 -0
- package/dist/transport/connect.js +23 -6
- package/dist/usage.js +23 -6
- package/package.json +3 -2
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
package/dist/replay-safety.d.ts
CHANGED
|
@@ -19,5 +19,11 @@ export type ReplayFrameAnalysis = {
|
|
|
19
19
|
semanticProgress: boolean;
|
|
20
20
|
barrier?: ReplayBarrierReason;
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Field numbers and wire types of a frame, nested three levels, without any
|
|
24
|
+
* payload bytes: `1:2{16:2{1:0}}`. Compare it with `agent.proto` when a frame
|
|
25
|
+
* is classified unknown.
|
|
26
|
+
*/
|
|
27
|
+
export declare function describeFrameLayout(payload: Uint8Array, depth?: number): string;
|
|
22
28
|
/** Classify one decoded server frame without performing any protocol side effects. */
|
|
23
29
|
export declare function analyzeReplayFrame(payload: Uint8Array, decoded: DecodedReplayFrame): ReplayFrameAnalysis;
|
package/dist/replay-safety.js
CHANGED
|
@@ -20,9 +20,25 @@ export class AttemptReplaySafety {
|
|
|
20
20
|
return failure;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
// Field numbers follow Cursor CLI 2026.09.28 `agent.v1`; the extracted
|
|
24
|
+
// agent.proto lags the live server.
|
|
25
|
+
// `InteractionUpdate` oneof members this client recognizes. Besides the
|
|
26
|
+
// declared ones: `thinking_completed` #5 (duration), `token_delta` #8 (count),
|
|
27
|
+
// `tool_call_delta` #15 (argument stream, like `partial_tool_call` #7),
|
|
28
|
+
// `routed_model` #24 (display name) and `tool_requests_listed` #27 (count)
|
|
29
|
+
// carry no output and no state. Other members stay barriers.
|
|
30
|
+
const INTERACTION_UPDATE_FIELDS = new Set([1, 2, 3, 4, 5, 7, 8, 13, 14, 15, 16, 17, 24, 27]);
|
|
31
|
+
/** `InteractionUpdate.message_started_at_ms` #25: a timestamp beside the oneof member. */
|
|
32
|
+
const INTERACTION_UPDATE_STARTED_AT = 25;
|
|
24
33
|
const INTERACTION_QUERY_FIELDS = new Set([2, 3, 4, 7, 8, 9, 10, 11, 12, 13, 14]);
|
|
25
34
|
const TOP_LEVEL_FIELDS = new Set([1, 2, 3, 4, 5, 7]);
|
|
35
|
+
/** `AgentServerMessage.ttft_breakdown` #8: timing metrics beside the oneof member. */
|
|
36
|
+
const SERVER_MESSAGE_TTFT = 8;
|
|
37
|
+
/** At most one occurrence of an optional field, with the expected wire type. */
|
|
38
|
+
function optionalField(fields, fn, wt) {
|
|
39
|
+
const matches = fields.filter((field) => field.fn === fn);
|
|
40
|
+
return matches.length <= 1 && matches.every((field) => field.wt === wt);
|
|
41
|
+
}
|
|
26
42
|
function nestedFields(topLevel, field) {
|
|
27
43
|
if (topLevel?.fn !== field || topLevel.wt !== 2 || !topLevel.bytes)
|
|
28
44
|
return [];
|
|
@@ -37,8 +53,14 @@ function analyzeExecWire(topLevel) {
|
|
|
37
53
|
exactMcpState: exactVariant(36),
|
|
38
54
|
};
|
|
39
55
|
}
|
|
56
|
+
// `KvServerMessage` = id #1, oneof get #2 / set #3, `span_context` #4 (tracing);
|
|
57
|
+
// Cursor sends the span on live KV requests.
|
|
40
58
|
function validKvWire(topLevel) {
|
|
41
|
-
const
|
|
59
|
+
const fields = nestedFields(topLevel, 4);
|
|
60
|
+
const spans = fields.filter((field) => field.fn === 4);
|
|
61
|
+
if (spans.length > 1 || spans.some((field) => field.wt !== 2))
|
|
62
|
+
return false;
|
|
63
|
+
const variants = fields.filter((field) => field.fn !== 1 && field.fn !== 4);
|
|
42
64
|
const variant = variants.length === 1 ? variants[0] : undefined;
|
|
43
65
|
const args = variant?.wt === 2 && variant.bytes
|
|
44
66
|
? (readAllFieldsStrict(variant.bytes) ?? [])
|
|
@@ -53,14 +75,23 @@ function validKvWire(topLevel) {
|
|
|
53
75
|
function validInteractionUpdateWire(topLevel, decoded) {
|
|
54
76
|
if (!decoded)
|
|
55
77
|
return true;
|
|
56
|
-
const
|
|
78
|
+
const all = nestedFields(topLevel, 1);
|
|
79
|
+
if (!optionalField(all, INTERACTION_UPDATE_STARTED_AT, 0))
|
|
80
|
+
return false;
|
|
81
|
+
const fields = all.filter((field) => field.fn !== INTERACTION_UPDATE_STARTED_AT);
|
|
57
82
|
const update = fields.length === 1 ? fields[0] : undefined;
|
|
58
83
|
if (!update || update.wt !== 2 || !INTERACTION_UPDATE_FIELDS.has(update.fn))
|
|
59
84
|
return false;
|
|
60
85
|
if (![1, 4].includes(update.fn))
|
|
61
86
|
return true;
|
|
62
87
|
const delta = update.bytes ? (readAllFieldsStrict(update.bytes) ?? []) : [];
|
|
63
|
-
|
|
88
|
+
const text = delta.filter((field) => field.fn === 1);
|
|
89
|
+
// #2 is `TextDeltaUpdate.is_server_notice` (bool) or
|
|
90
|
+
// `ThinkingDeltaUpdate.thinking_style` (enum); both varints.
|
|
91
|
+
const flag = delta.filter((field) => field.fn === 2);
|
|
92
|
+
return text.length === 1 && text[0].wt === 2
|
|
93
|
+
&& optionalField(delta, 2, 0)
|
|
94
|
+
&& delta.length === text.length + flag.length;
|
|
64
95
|
}
|
|
65
96
|
function validInteractionQueryWire(topLevel, decoded) {
|
|
66
97
|
if (!decoded)
|
|
@@ -96,10 +127,29 @@ function hasSemanticProgress(decoded) {
|
|
|
96
127
|
|| !!decoded.interactionQuery
|
|
97
128
|
|| !!decoded.checkpointBytes?.length;
|
|
98
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Field numbers and wire types of a frame, nested three levels, without any
|
|
132
|
+
* payload bytes: `1:2{16:2{1:0}}`. Compare it with `agent.proto` when a frame
|
|
133
|
+
* is classified unknown.
|
|
134
|
+
*/
|
|
135
|
+
export function describeFrameLayout(payload, depth = 3) {
|
|
136
|
+
const fields = readAllFieldsStrict(payload);
|
|
137
|
+
if (!fields)
|
|
138
|
+
return "?";
|
|
139
|
+
return fields.map((field) => {
|
|
140
|
+
const nested = depth > 1 && field.wt === 2 && field.bytes && field.bytes.length > 0
|
|
141
|
+
? readAllFieldsStrict(field.bytes)
|
|
142
|
+
: undefined;
|
|
143
|
+
const inner = nested && nested.length > 0 ? `{${describeFrameLayout(field.bytes, depth - 1)}}` : "";
|
|
144
|
+
return `${field.fn}:${field.wt}${inner}`;
|
|
145
|
+
}).join(",");
|
|
146
|
+
}
|
|
99
147
|
/** Classify one decoded server frame without performing any protocol side effects. */
|
|
100
148
|
export function analyzeReplayFrame(payload, decoded) {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
149
|
+
const allTopLevel = readAllFieldsStrict(payload) ?? [];
|
|
150
|
+
const ttftValid = optionalField(allTopLevel, SERVER_MESSAGE_TTFT, 2);
|
|
151
|
+
const topLevelFields = allTopLevel.filter((field) => field.fn !== SERVER_MESSAGE_TTFT);
|
|
152
|
+
const topLevel = ttftValid && topLevelFields.length === 1 ? topLevelFields[0] : undefined;
|
|
103
153
|
const exec = analyzeExecWire(topLevel);
|
|
104
154
|
const validKv = validKvWire(topLevel);
|
|
105
155
|
const malformed = !topLevel
|
package/dist/session.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type CursorProviderError } from "./errors.js";
|
|
|
3
3
|
import { type SessionActivitySource } from "./activity.js";
|
|
4
4
|
import type { HostSubagentCatalog, HostToolDialect, OpencodeToolDef, ToolAliasRegistry } from "./protocol/tools.js";
|
|
5
5
|
import type { CursorConversationTokenDetails } from "./protocol/token-details.js";
|
|
6
|
+
import type { ParallelStepState } from "./parallel-step.js";
|
|
6
7
|
export type Frame = {
|
|
7
8
|
flags: number;
|
|
8
9
|
payload: Uint8Array;
|
|
@@ -95,6 +96,8 @@ export type CursorSession = {
|
|
|
95
96
|
* (Cursor resets them per stream) can't cross-deliver results.
|
|
96
97
|
*/
|
|
97
98
|
sessionId: string;
|
|
99
|
+
/** AgentRunRequest.run_id, for context injections bound to this Run. */
|
|
100
|
+
runId?: string;
|
|
98
101
|
/**
|
|
99
102
|
* Cursor conversation_id for this Run — used to store/echo
|
|
100
103
|
* conversation_checkpoint_update (CLI parity).
|
|
@@ -152,8 +155,24 @@ export type CursorSession = {
|
|
|
152
155
|
toolCatalog?: OpencodeToolDef[];
|
|
153
156
|
/** Configured MCP server ids used to split flattened host tool names. */
|
|
154
157
|
knownMcpServers?: string[];
|
|
158
|
+
/** The Run's model accepts images, so held-Run exec results may carry them. */
|
|
159
|
+
supportsImages?: boolean;
|
|
155
160
|
stream: BidiStream;
|
|
156
161
|
frames: AsyncIterator<Frame>;
|
|
162
|
+
/** An iterator read retained across an idle-guard timeout. */
|
|
163
|
+
pendingFrameRead?: Promise<IteratorResult<Frame>>;
|
|
164
|
+
/**
|
|
165
|
+
* One-slot pushback for a terminal update that must start the next pump pass
|
|
166
|
+
* after the host settles emitted tools. Timed-out reads use pendingFrameRead.
|
|
167
|
+
*/
|
|
168
|
+
pushbackFrame?: Frame;
|
|
169
|
+
/**
|
|
170
|
+
* In-progress parallel tool-call step (field 27 hold). Cleared when the step
|
|
171
|
+
* finishes; added to priorParallelStepCallIds for late-completion filtering.
|
|
172
|
+
*/
|
|
173
|
+
parallelStep?: ParallelStepState;
|
|
174
|
+
/** Resolved call ids from prior steps of this Run — ignore late completions. */
|
|
175
|
+
priorParallelStepCallIds?: Set<string>;
|
|
157
176
|
pending: Map<number, PendingExec>;
|
|
158
177
|
/**
|
|
159
178
|
* Cursor display tool calls (tool_call_started) awaiting either an exec or a
|
|
@@ -167,6 +186,11 @@ export type CursorSession = {
|
|
|
167
186
|
* completed list so host todos still receive a replace-all snapshot.
|
|
168
187
|
*/
|
|
169
188
|
mirroredTodos?: Array<Record<string, unknown>>;
|
|
189
|
+
/**
|
|
190
|
+
* CreatePlan display calls whose plan was deferred to the host plan agent.
|
|
191
|
+
* Their completed display carries no recorded plan, so it is not mirrored.
|
|
192
|
+
*/
|
|
193
|
+
deferredCreatePlanCalls?: Set<string>;
|
|
170
194
|
/**
|
|
171
195
|
* Legacy edit calls whose authoritative exec path is still in progress.
|
|
172
196
|
* Cursor implements these as read -> whole-file write; retaining the path
|
|
@@ -302,7 +326,7 @@ export declare class SessionManager {
|
|
|
302
326
|
* Callers must cancel the session heartbeat and wait for the old stream's
|
|
303
327
|
* write chain before this, so an in-flight heartbeat cannot close the session.
|
|
304
328
|
*/
|
|
305
|
-
replaceStream(session: CursorSession, next: BidiStream): void;
|
|
329
|
+
replaceStream(session: CursorSession, next: BidiStream, runId?: string): void;
|
|
306
330
|
private subscribeTerminal;
|
|
307
331
|
private key;
|
|
308
332
|
close(session: CursorSession, reason?: SessionCloseReason, error?: CursorProviderError): void;
|
|
@@ -325,5 +349,15 @@ export declare class SessionManager {
|
|
|
325
349
|
private putTombstone;
|
|
326
350
|
private isTerminalReason;
|
|
327
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Read one frame, retaining ownership of the iterator read when a timeout
|
|
354
|
+
* wins. Every reader uses this function so a continuation cannot overtake a
|
|
355
|
+
* late frame (or discard its EOF/error).
|
|
356
|
+
*/
|
|
357
|
+
export declare function readSessionFrame(session: CursorSession): Promise<IteratorResult<Frame>>;
|
|
358
|
+
export declare function readSessionFrame(session: CursorSession, timeoutMs: number): Promise<IteratorResult<Frame> | {
|
|
359
|
+
done: true;
|
|
360
|
+
timedOut: true;
|
|
361
|
+
}>;
|
|
328
362
|
export declare const sessionManager: SessionManager;
|
|
329
363
|
export {};
|
package/dist/session.js
CHANGED
|
@@ -386,7 +386,7 @@ export class SessionManager {
|
|
|
386
386
|
* Callers must cancel the session heartbeat and wait for the old stream's
|
|
387
387
|
* write chain before this, so an in-flight heartbeat cannot close the session.
|
|
388
388
|
*/
|
|
389
|
-
replaceStream(session, next) {
|
|
389
|
+
replaceStream(session, next, runId) {
|
|
390
390
|
if (session.closed)
|
|
391
391
|
throw new CursorProtocolError("Cannot replace stream on a closed Cursor session");
|
|
392
392
|
session.heartbeatCancel?.();
|
|
@@ -395,7 +395,13 @@ export class SessionManager {
|
|
|
395
395
|
session.deferredTerminalReason = null;
|
|
396
396
|
const old = session.stream;
|
|
397
397
|
session.stream = next;
|
|
398
|
+
if (runId !== undefined)
|
|
399
|
+
session.runId = runId;
|
|
398
400
|
session.frames = next.frames()[Symbol.asyncIterator]();
|
|
401
|
+
session.pendingFrameRead = undefined;
|
|
402
|
+
session.pushbackFrame = undefined;
|
|
403
|
+
session.parallelStep = undefined;
|
|
404
|
+
session.priorParallelStepCallIds = undefined;
|
|
399
405
|
this.subscribeTerminal(session);
|
|
400
406
|
try {
|
|
401
407
|
old.destroy();
|
|
@@ -444,6 +450,10 @@ export class SessionManager {
|
|
|
444
450
|
this.putTombstone(key, reason);
|
|
445
451
|
}
|
|
446
452
|
session.pending.clear();
|
|
453
|
+
session.pendingFrameRead = undefined;
|
|
454
|
+
session.pushbackFrame = undefined;
|
|
455
|
+
session.parallelStep = undefined;
|
|
456
|
+
session.priorParallelStepCallIds = undefined;
|
|
447
457
|
session.pumpOwner = null;
|
|
448
458
|
session.pumpActive = false;
|
|
449
459
|
session.displayToolCalls?.clear();
|
|
@@ -570,6 +580,40 @@ export class SessionManager {
|
|
|
570
580
|
return !["ordinary-cleanup", "turn-ended", "initial-write-failed"].includes(reason);
|
|
571
581
|
}
|
|
572
582
|
}
|
|
583
|
+
export async function readSessionFrame(session, timeoutMs) {
|
|
584
|
+
if (session.pushbackFrame) {
|
|
585
|
+
const value = session.pushbackFrame;
|
|
586
|
+
session.pushbackFrame = undefined;
|
|
587
|
+
return { done: false, value };
|
|
588
|
+
}
|
|
589
|
+
const pending = session.pendingFrameRead ??= session.frames.next();
|
|
590
|
+
// A timed-out read can reject while no pump is consuming it. Preserve the
|
|
591
|
+
// rejection for the next reader without an unhandled detached promise.
|
|
592
|
+
void pending.catch(() => { });
|
|
593
|
+
let timer;
|
|
594
|
+
try {
|
|
595
|
+
const result = timeoutMs === undefined ? await pending : await Promise.race([
|
|
596
|
+
pending,
|
|
597
|
+
new Promise(resolve => {
|
|
598
|
+
timer = setTimeout(() => resolve({ done: true, timedOut: true }), timeoutMs);
|
|
599
|
+
timer.unref?.();
|
|
600
|
+
}),
|
|
601
|
+
]);
|
|
602
|
+
if (!("timedOut" in result) && session.pendingFrameRead === pending) {
|
|
603
|
+
session.pendingFrameRead = undefined;
|
|
604
|
+
}
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (session.pendingFrameRead === pending)
|
|
609
|
+
session.pendingFrameRead = undefined;
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
if (timer)
|
|
614
|
+
clearTimeout(timer);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
573
617
|
export const sessionManager = new SessionManager();
|
|
574
618
|
function installProcessCleanup() {
|
|
575
619
|
const dispose = () => {
|
package/dist/todo-store.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type SessionTodo = {
|
|
|
22
22
|
/** Normalize one host/Cursor todo item. Drops entries with empty content. */
|
|
23
23
|
export declare function normalizeSessionTodo(value: unknown, index: number): SessionTodo | undefined;
|
|
24
24
|
export declare function normalizeSessionTodos(value: unknown): SessionTodo[];
|
|
25
|
+
/** Fill omitted defaults before validation without dropping malformed entries. */
|
|
26
|
+
export declare function normalizeOpencodeTodoArgs(args: Record<string, unknown>): Record<string, unknown>;
|
|
25
27
|
export declare function getSessionTodos(sessionID: string): SessionTodo[];
|
|
26
28
|
export declare function setSessionTodos(sessionID: string, todos: unknown): SessionTodo[];
|
|
27
29
|
export declare function clearSessionTodos(sessionID: string): void;
|
package/dist/todo-store.js
CHANGED
|
@@ -54,6 +54,25 @@ export function normalizeSessionTodos(value) {
|
|
|
54
54
|
}
|
|
55
55
|
return out;
|
|
56
56
|
}
|
|
57
|
+
/** Fill omitted defaults before validation without dropping malformed entries. */
|
|
58
|
+
export function normalizeOpencodeTodoArgs(args) {
|
|
59
|
+
if (!Array.isArray(args.todos))
|
|
60
|
+
return args;
|
|
61
|
+
return {
|
|
62
|
+
...args,
|
|
63
|
+
todos: args.todos.map((item, index) => {
|
|
64
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
65
|
+
return item;
|
|
66
|
+
const todo = item;
|
|
67
|
+
return {
|
|
68
|
+
...todo,
|
|
69
|
+
...(todo.id === undefined ? { id: String(index + 1) } : {}),
|
|
70
|
+
...(todo.status === undefined ? { status: "pending" } : {}),
|
|
71
|
+
...(todo.priority === undefined ? { priority: "medium" } : {}),
|
|
72
|
+
};
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
57
76
|
export function getSessionTodos(sessionID) {
|
|
58
77
|
return lists.get(sessionID)?.map((todo) => ({ ...todo })) ?? [];
|
|
59
78
|
}
|
|
@@ -73,6 +73,8 @@ export declare function resolveAgentOrigin(baseURL: string): string;
|
|
|
73
73
|
export declare function closeCachedHttp2SessionsForTests(): void;
|
|
74
74
|
/** Node-runtime regression hook; production callers use getSession(). */
|
|
75
75
|
export declare function installSessionInvalidationForTests(origin: string, session: http2.ClientHttp2Session): void;
|
|
76
|
+
/** Node-runtime regression hook: cache a connected local session for `origin`. */
|
|
77
|
+
export declare function cacheHttp2SessionForTests(origin: string, session: http2.ClientHttp2Session): void;
|
|
76
78
|
export declare function getSession(baseURL: string, options?: {
|
|
77
79
|
pingTimeoutMs?: number;
|
|
78
80
|
}): Promise<http2.ClientHttp2Session>;
|
|
@@ -259,6 +259,19 @@ function dropSession(origin, session) {
|
|
|
259
259
|
if (_http2Sessions.get(origin) === session)
|
|
260
260
|
_http2Sessions.delete(origin);
|
|
261
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Stop offering `session` for new Runs and close it so in-flight streams can
|
|
264
|
+
* drain. Used by age rotation and a failed reuse ping — never
|
|
265
|
+
* `destroy()`, which would abort every sibling Run multiplexed on the session
|
|
266
|
+
* mid-output (those Runs cannot be replayed after visible activity).
|
|
267
|
+
*/
|
|
268
|
+
function retireCachedSession(origin, session) {
|
|
269
|
+
dropSession(origin, session);
|
|
270
|
+
try {
|
|
271
|
+
session.close();
|
|
272
|
+
}
|
|
273
|
+
catch { /* already closed */ }
|
|
274
|
+
}
|
|
262
275
|
/** Test cleanup for local HTTP/2 fixtures; production sessions stay process-cached. */
|
|
263
276
|
export function closeCachedHttp2SessionsForTests() {
|
|
264
277
|
for (const session of _http2Sessions.values()) {
|
|
@@ -354,6 +367,12 @@ function installSessionInvalidation(origin, session) {
|
|
|
354
367
|
export function installSessionInvalidationForTests(origin, session) {
|
|
355
368
|
installSessionInvalidation(origin, session);
|
|
356
369
|
}
|
|
370
|
+
/** Node-runtime regression hook: cache a connected local session for `origin`. */
|
|
371
|
+
export function cacheHttp2SessionForTests(origin, session) {
|
|
372
|
+
installSessionInvalidation(origin, session);
|
|
373
|
+
_http2SessionCreatedAt.set(session, Date.now());
|
|
374
|
+
_http2Sessions.set(origin, session);
|
|
375
|
+
}
|
|
357
376
|
function invalidateSession(origin, session) {
|
|
358
377
|
dropSession(origin, session);
|
|
359
378
|
_http2SessionListenerCleanup.get(session)?.();
|
|
@@ -535,11 +554,7 @@ export function getSession(baseURL, options = {}) {
|
|
|
535
554
|
const createdAt = _http2SessionCreatedAt.get(existing) ?? 0;
|
|
536
555
|
if (!shouldReuseHttp2Session(existing, createdAt)) {
|
|
537
556
|
trace(`h2 session rotate: origin=${origin} ageMs=${Math.max(0, Date.now() - createdAt)}`);
|
|
538
|
-
|
|
539
|
-
try {
|
|
540
|
-
existing.close();
|
|
541
|
-
}
|
|
542
|
-
catch { /* already closed */ }
|
|
557
|
+
retireCachedSession(origin, existing);
|
|
543
558
|
}
|
|
544
559
|
else {
|
|
545
560
|
try {
|
|
@@ -550,8 +565,10 @@ export function getSession(baseURL, options = {}) {
|
|
|
550
565
|
}
|
|
551
566
|
}
|
|
552
567
|
catch (error) {
|
|
568
|
+
// Sibling Runs may still be streaming on this session; destroy() would
|
|
569
|
+
// abort them mid-output, where they cannot be replayed.
|
|
553
570
|
trace(`h2 cached session ping failed: origin=${origin} err=${error.message}`);
|
|
554
|
-
|
|
571
|
+
retireCachedSession(origin, existing);
|
|
555
572
|
}
|
|
556
573
|
}
|
|
557
574
|
}
|
package/dist/usage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { currentCursorTokenBreakdown } from "./protocol/token-details.js";
|
|
1
2
|
/** Non-negative integer counter from a Cursor `turn_ended` field. */
|
|
2
3
|
export function turnEndedCounter(te, key) {
|
|
3
4
|
const value = te[key];
|
|
@@ -75,7 +76,7 @@ function usageRatio(part, total) {
|
|
|
75
76
|
return total > 0 ? `${(part / total * 100).toFixed(1)}%` : "n/a";
|
|
76
77
|
}
|
|
77
78
|
function categoryTokens(details) {
|
|
78
|
-
return new Map(details?.
|
|
79
|
+
return new Map(currentCursorTokenBreakdown(details)?.categories.map((category) => [
|
|
79
80
|
category.id || category.label || "(unnamed)",
|
|
80
81
|
category.estimatedTokens,
|
|
81
82
|
]) ?? []);
|
|
@@ -97,7 +98,7 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
|
|
|
97
98
|
const rawUncached = Math.max(0, rawInput - rawRead - rawWrite);
|
|
98
99
|
const priorCategories = categoryTokens(prior);
|
|
99
100
|
const currentCategories = categoryTokens(current);
|
|
100
|
-
const categoriesComparable = !!prior
|
|
101
|
+
const categoriesComparable = !!currentCursorTokenBreakdown(prior) && !!currentCursorTokenBreakdown(current);
|
|
101
102
|
const categoryDelta = {};
|
|
102
103
|
let sameSizedCategoryTokens = 0;
|
|
103
104
|
if (categoriesComparable) {
|
|
@@ -132,6 +133,13 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
|
|
|
132
133
|
&& toolsDelta !== 0
|
|
133
134
|
? "client-overlay-changed"
|
|
134
135
|
: "none";
|
|
136
|
+
// Occupancy measures context size, not cache reuse. A zero counter on an
|
|
137
|
+
// interaction turn cannot distinguish omitted accounting from a cache miss.
|
|
138
|
+
const interactionZeroRead = stats.startedWithCheckpoint
|
|
139
|
+
&& rawRead === 0
|
|
140
|
+
&& typeof prior?.usedTokens === "number"
|
|
141
|
+
&& prior.usedTokens > 0
|
|
142
|
+
&& (stats.createPlanInTurn === true || stats.switchModeInTurn === true);
|
|
135
143
|
return [
|
|
136
144
|
"cache diagnosis:",
|
|
137
145
|
`sessionKey=${stats.sessionKey ?? "-"}`,
|
|
@@ -164,6 +172,12 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
|
|
|
164
172
|
`execRequests=${stats.execRequests}`,
|
|
165
173
|
`createPlanInTurn=${stats.createPlanInTurn === true}`,
|
|
166
174
|
`switchModeInTurn=${stats.switchModeInTurn === true}`,
|
|
175
|
+
...(interactionZeroRead
|
|
176
|
+
? [
|
|
177
|
+
"turnEndedCacheRead=zero-interaction",
|
|
178
|
+
"cacheReuseEvidence=unavailable",
|
|
179
|
+
]
|
|
180
|
+
: []),
|
|
167
181
|
"perModelCallCache=unavailable",
|
|
168
182
|
].join(" ");
|
|
169
183
|
}
|
|
@@ -189,8 +203,9 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
|
|
|
189
203
|
const breakdown = tokenDetails?.breakdown;
|
|
190
204
|
const categorySum = breakdown?.categories.reduce((sum, category) => sum + category.estimatedTokens, 0);
|
|
191
205
|
const breakdownMatch = breakdown && categorySum !== undefined
|
|
192
|
-
? breakdown.totalUsedTokens
|
|
193
|
-
|
|
206
|
+
? categorySum !== breakdown.totalUsedTokens
|
|
207
|
+
? false
|
|
208
|
+
: currentCursorTokenBreakdown(tokenDetails) ? true : "stale"
|
|
194
209
|
: undefined;
|
|
195
210
|
const rawCached = counters.cacheRead + counters.cacheWrite;
|
|
196
211
|
const sentCached = cacheRead + cacheWrite;
|
|
@@ -208,7 +223,7 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
|
|
|
208
223
|
projectedOpenCodeTotal === sentTotal &&
|
|
209
224
|
(cacheRatioMatch ?? true) &&
|
|
210
225
|
(!tokenDetails || sentTotal === tokenDetails.usedTokens) &&
|
|
211
|
-
|
|
226
|
+
breakdownMatch !== false
|
|
212
227
|
? "ok"
|
|
213
228
|
: "mismatch";
|
|
214
229
|
return [
|
|
@@ -267,7 +282,9 @@ export function occupancyValidationCounters(details, prior) {
|
|
|
267
282
|
return {
|
|
268
283
|
inputTokens: used,
|
|
269
284
|
outputTokens: used > 0 ? 1 : 0,
|
|
270
|
-
|
|
285
|
+
// Cursor can shrink the context between checkpoints; the prior prefix
|
|
286
|
+
// cannot be larger than what is in context now.
|
|
287
|
+
cacheRead: Math.min(used, Math.max(0, Math.trunc(prior?.usedTokens ?? 0))),
|
|
271
288
|
cacheWrite: 0,
|
|
272
289
|
reasoningTokens: 0,
|
|
273
290
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-opencode-provider",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,7 +70,8 @@
|
|
|
70
70
|
"LICENSE",
|
|
71
71
|
"DISCLAIMER.md",
|
|
72
72
|
"SECURITY.md",
|
|
73
|
-
"README.md"
|
|
73
|
+
"README.md",
|
|
74
|
+
"CHANGELOG.md"
|
|
74
75
|
],
|
|
75
76
|
"scripts": {
|
|
76
77
|
"build": "tsc",
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Queue OpenCode's post-plan-exit kickoff after CreatePlan approval.
|
|
3
|
-
*
|
|
4
|
-
* Native `plan_exit` asks the user, then injects a synthetic user message with
|
|
5
|
-
* `agent: "build"` so a new turn starts implementing. Our CreatePlan → question
|
|
6
|
-
* path already asks; this module is the missing second half. A host plugin
|
|
7
|
-
* installs the prompt handler; the language model records a request after Yes,
|
|
8
|
-
* and the outer stream wrapper flushes it only after the current `doStream`
|
|
9
|
-
* result stream has settled. This prevents a second generation from starting
|
|
10
|
-
* while the continuation Run is still live.
|
|
11
|
-
*/
|
|
12
|
-
export type PlanExecutionKickoffInput = {
|
|
13
|
-
sessionID: string;
|
|
14
|
-
/** Filesystem path (or relative display path) of the approved plan. */
|
|
15
|
-
planPath: string;
|
|
16
|
-
/** Concrete Cursor Run that owns the approval. Guards recovery/supersession. */
|
|
17
|
-
cursorSessionID?: string;
|
|
18
|
-
};
|
|
19
|
-
export type PlanExecutionKickoffFn = (input: PlanExecutionKickoffInput) => void | Promise<void>;
|
|
20
|
-
/** Upstream `PlanExitTool` wording — keep verbatim so behaviour matches OpenCode. */
|
|
21
|
-
export declare function createPlanExecutionKickoffText(planPath: string): string;
|
|
22
|
-
/**
|
|
23
|
-
* Prefer a worktree-relative label when the plan sits under `workspaceRoot`,
|
|
24
|
-
* matching OpenCode's `path.relative(instance.worktree, Session.plan(...))`.
|
|
25
|
-
* Otherwise keep the absolute path (global data `plans/` is outside the tree).
|
|
26
|
-
*/
|
|
27
|
-
export declare function formatPlanKickoffPath(planPath: string, workspaceRoot?: string): string;
|
|
28
|
-
/** Decode a `file://` plan URI to a filesystem path; pass other strings through. */
|
|
29
|
-
export declare function planPathFromUri(planUri: string): string;
|
|
30
|
-
export type PlanExecutionKickoffState = PlanExecutionKickoffInput & {
|
|
31
|
-
status: "pending" | "failed";
|
|
32
|
-
attempts: number;
|
|
33
|
-
lastError?: string;
|
|
34
|
-
};
|
|
35
|
-
/** Install (or clear) the host kickoff. */
|
|
36
|
-
export declare function setPlanExecutionKickoff(fn: PlanExecutionKickoffFn | undefined): void;
|
|
37
|
-
export declare function hasPlanExecutionKickoff(): boolean;
|
|
38
|
-
/** Record the approved plan; execution is flushed after the stream settles. */
|
|
39
|
-
export declare function queuePlanExecutionKickoff(input: PlanExecutionKickoffInput): boolean;
|
|
40
|
-
/** Drop any approval state owned by a deleted host session. */
|
|
41
|
-
export declare function cancelPlanExecutionKickoff(sessionID: string | undefined): void;
|
|
42
|
-
/**
|
|
43
|
-
* Run one pending kickoff only after the owning Cursor Run reached a terminal,
|
|
44
|
-
* idle state. Returns true only after successful host handoff.
|
|
45
|
-
*/
|
|
46
|
-
export declare function flushPlanExecutionKickoff(sessionID: string | undefined, options?: {
|
|
47
|
-
cursorSessionID?: string;
|
|
48
|
-
terminal?: boolean;
|
|
49
|
-
pumpActive?: boolean;
|
|
50
|
-
pendingExecs?: number;
|
|
51
|
-
}): Promise<boolean>;
|
|
52
|
-
/** Consume a user-visible warning on the next provider turn. */
|
|
53
|
-
export declare function takePlanExecutionKickoffWarning(sessionID: string | undefined): string | undefined;
|
|
54
|
-
/** Retry state remains explicit; no timer or automatic loop fires it. */
|
|
55
|
-
export declare function planExecutionKickoffState(sessionID: string | undefined): PlanExecutionKickoffState | undefined;
|
|
56
|
-
/** Test helper: clear the registered kickoff and pending approvals. */
|
|
57
|
-
export declare function resetPlanExecutionKickoffForTests(): void;
|