cursor-opencode-provider 0.7.6 → 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 +36 -15
- package/SECURITY.md +43 -7
- package/dist/agent-url.js +2 -2
- package/dist/auth-renewal.d.ts +85 -0
- package/dist/auth-renewal.js +284 -0
- package/dist/auth.d.ts +38 -22
- package/dist/auth.js +79 -96
- package/dist/classic-tools.d.ts +15 -0
- package/dist/classic-tools.js +45 -0
- package/dist/context/build.d.ts +62 -7
- package/dist/context/build.js +127 -95
- package/dist/context/dynamic-catalog.d.ts +2 -58
- package/dist/context/dynamic-catalog.js +16 -161
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +42 -15
- package/dist/context/index.d.ts +3 -1
- package/dist/context/index.js +3 -1
- package/dist/context/overlay.d.ts +2 -23
- package/dist/context/overlay.js +6 -30
- package/dist/context/paths.d.ts +51 -21
- package/dist/context/paths.js +78 -27
- package/dist/context/rules.d.ts +3 -15
- package/dist/context/rules.js +10 -186
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +31 -8
- package/dist/context/skills.js +187 -86
- package/dist/debug.d.ts +2 -0
- package/dist/debug.js +4 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +27 -3
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +119 -15
- 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 +35 -23
- package/dist/index.d.ts +10 -0
- package/dist/language-model.d.ts +86 -25
- package/dist/language-model.js +1295 -369
- 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/integration.d.ts +30 -7
- package/dist/opencode2/integration.js +75 -46
- 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 +58 -56
- package/dist/plugin.d.ts +0 -10
- package/dist/plugin.js +207 -127
- 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/blob-store.js +3 -2
- package/dist/protocol/conversation-bind.js +1 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +11 -1
- package/dist/protocol/conversation-state.d.ts +2 -0
- package/dist/protocol/conversation-state.js +19 -0
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +35 -24
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +96 -18
- package/dist/protocol/request.d.ts +16 -9
- package/dist/protocol/request.js +17 -25
- 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 +33 -11
- package/dist/protocol/tools.js +165 -103
- package/dist/protocol/turn-provenance.d.ts +52 -0
- package/dist/protocol/turn-provenance.js +174 -0
- package/dist/replay-safety.d.ts +6 -0
- package/dist/replay-safety.js +56 -6
- package/dist/session.d.ts +44 -2
- package/dist/session.js +45 -1
- package/dist/shared.d.ts +7 -0
- package/dist/shared.js +7 -0
- 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/context/agents.d.ts +0 -12
- package/dist/context/agents.js +0 -136
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// A sticky Cursor conversation only knows what Cursor itself produced. When the
|
|
2
|
+
// host answers a turn with another model (another provider or a local model)
|
|
3
|
+
// and then comes back, resuming the old checkpoint hides that work from Cursor.
|
|
4
|
+
// A direct switch between two Cursor models is not foreign history: like the
|
|
5
|
+
// Cursor CLI, the next Run resumes the same conversation with the new model.
|
|
6
|
+
// Remember what this provider emitted in its most recent step per OpenCode
|
|
7
|
+
// session so the next fresh Run can tell whether the host's latest assistant
|
|
8
|
+
// turn is ours.
|
|
9
|
+
/** Mirrors MAX_TURN_STATE_SESSIONS; evicted sessions re-hydrate from disk. */
|
|
10
|
+
export const MAX_PROVENANCE_SESSIONS = 256;
|
|
11
|
+
// Per-session footprint stays small: a step is identified by its first tool
|
|
12
|
+
// call ids and the first 4 KiB of its text (and of its reasoning plus text),
|
|
13
|
+
// which is enough to tell turns apart.
|
|
14
|
+
const MAX_TOOL_CALL_IDS = 64;
|
|
15
|
+
const MAX_TEXT_CHARS = 4 * 1024;
|
|
16
|
+
const provenanceBySession = new Map();
|
|
17
|
+
function normalizeText(text) {
|
|
18
|
+
return text.replace(/\s+/g, "");
|
|
19
|
+
}
|
|
20
|
+
function touch(sessionKey, entry) {
|
|
21
|
+
provenanceBySession.delete(sessionKey);
|
|
22
|
+
provenanceBySession.set(sessionKey, entry);
|
|
23
|
+
while (provenanceBySession.size > MAX_PROVENANCE_SESSIONS) {
|
|
24
|
+
const oldest = provenanceBySession.keys().next().value;
|
|
25
|
+
if (!oldest)
|
|
26
|
+
break;
|
|
27
|
+
provenanceBySession.delete(oldest);
|
|
28
|
+
}
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
function entryFor(sessionKey, conversationId) {
|
|
32
|
+
const existing = provenanceBySession.get(sessionKey);
|
|
33
|
+
if (existing && existing.conversationId === conversationId)
|
|
34
|
+
return touch(sessionKey, existing);
|
|
35
|
+
return touch(sessionKey, { conversationId, toolCallIds: [], text: "", textWithReasoning: "", stepPending: false });
|
|
36
|
+
}
|
|
37
|
+
function appendCapped(value, delta) {
|
|
38
|
+
return value.length < MAX_TEXT_CHARS ? (value + delta).slice(0, MAX_TEXT_CHARS) : value;
|
|
39
|
+
}
|
|
40
|
+
function contentEntry(sessionKey, conversationId) {
|
|
41
|
+
const entry = entryFor(sessionKey, conversationId);
|
|
42
|
+
if (entry.stepPending) {
|
|
43
|
+
entry.stepPending = false;
|
|
44
|
+
entry.toolCallIds = [];
|
|
45
|
+
entry.text = "";
|
|
46
|
+
entry.textWithReasoning = "";
|
|
47
|
+
}
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Mark the start of one host step (one doStream). The previous step stays
|
|
52
|
+
* recorded until this one emits content, because hosts drop empty assistant
|
|
53
|
+
* turns from history.
|
|
54
|
+
*/
|
|
55
|
+
export function beginEmittedStep(sessionKey, conversationId) {
|
|
56
|
+
entryFor(sessionKey, conversationId).stepPending = true;
|
|
57
|
+
}
|
|
58
|
+
/** Record one stream part this provider handed to the host. */
|
|
59
|
+
export function recordEmittedPart(sessionKey, conversationId, part) {
|
|
60
|
+
if ((part.type === "text-delta" || part.type === "reasoning-delta") && typeof part.delta === "string") {
|
|
61
|
+
const delta = normalizeText(part.delta);
|
|
62
|
+
if (!delta)
|
|
63
|
+
return;
|
|
64
|
+
const entry = contentEntry(sessionKey, conversationId);
|
|
65
|
+
entry.textWithReasoning = appendCapped(entry.textWithReasoning, delta);
|
|
66
|
+
if (part.type === "text-delta")
|
|
67
|
+
entry.text = appendCapped(entry.text, delta);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (part.type === "tool-call" && typeof part.toolCallId === "string" && part.toolCallId) {
|
|
71
|
+
const entry = contentEntry(sessionKey, conversationId);
|
|
72
|
+
if (entry.toolCallIds.length < MAX_TOOL_CALL_IDS)
|
|
73
|
+
entry.toolCallIds.push(part.toolCallId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Bind provenance to the conversation a Run was opened on. A reminted
|
|
78
|
+
* conversation starts with an empty record, so it never inherits the previous
|
|
79
|
+
* conversation's steps.
|
|
80
|
+
*/
|
|
81
|
+
export function trackTurnProvenance(sessionKey, conversationId) {
|
|
82
|
+
entryFor(sessionKey, conversationId);
|
|
83
|
+
}
|
|
84
|
+
export function getTurnProvenance(sessionKey) {
|
|
85
|
+
const entry = provenanceBySession.get(sessionKey);
|
|
86
|
+
if (!entry)
|
|
87
|
+
return undefined;
|
|
88
|
+
return {
|
|
89
|
+
conversationId: entry.conversationId,
|
|
90
|
+
toolCallIds: [...entry.toolCallIds],
|
|
91
|
+
text: entry.text,
|
|
92
|
+
textWithReasoning: entry.textWithReasoning,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function restoreTurnProvenance(sessionKey, value) {
|
|
96
|
+
touch(sessionKey, { ...value, toolCallIds: [...value.toolCallIds], stepPending: false });
|
|
97
|
+
}
|
|
98
|
+
export function resetTurnProvenanceForTests() {
|
|
99
|
+
provenanceBySession.clear();
|
|
100
|
+
}
|
|
101
|
+
export function serializeTurnProvenance(value) {
|
|
102
|
+
return JSON.stringify(value);
|
|
103
|
+
}
|
|
104
|
+
export function parseTurnProvenance(raw) {
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(raw);
|
|
107
|
+
if (typeof value.conversationId !== "string" || !value.conversationId)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
conversationId: value.conversationId,
|
|
111
|
+
toolCallIds: Array.isArray(value.toolCallIds)
|
|
112
|
+
? value.toolCallIds.filter((id) => typeof id === "string").slice(0, MAX_TOOL_CALL_IDS)
|
|
113
|
+
: [],
|
|
114
|
+
text: typeof value.text === "string" ? value.text.slice(0, MAX_TEXT_CHARS) : "",
|
|
115
|
+
textWithReasoning: typeof value.textWithReasoning === "string"
|
|
116
|
+
? value.textWithReasoning.slice(0, MAX_TEXT_CHARS)
|
|
117
|
+
: "",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Decide whether the host history moved past this Cursor conversation.
|
|
126
|
+
* Returns undefined when there is no evidence either way (no record for this
|
|
127
|
+
* conversation, or no assistant turn yet), so unknown state never forces a
|
|
128
|
+
* rebase.
|
|
129
|
+
*/
|
|
130
|
+
export function detectForeignHistory(input) {
|
|
131
|
+
if (!input.sessionKey)
|
|
132
|
+
return undefined;
|
|
133
|
+
const entry = provenanceBySession.get(input.sessionKey);
|
|
134
|
+
if (!entry || entry.conversationId !== input.conversationId)
|
|
135
|
+
return undefined;
|
|
136
|
+
let lastAssistant;
|
|
137
|
+
for (let i = input.prompt.length - 1; i >= 0; i--) {
|
|
138
|
+
if (input.prompt[i].role === "assistant") {
|
|
139
|
+
lastAssistant = input.prompt[i];
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!lastAssistant || !Array.isArray(lastAssistant.content))
|
|
144
|
+
return undefined;
|
|
145
|
+
const toolCallIds = [];
|
|
146
|
+
let text = "";
|
|
147
|
+
let textWithReasoning = "";
|
|
148
|
+
for (const part of lastAssistant.content) {
|
|
149
|
+
if (part.type === "tool-call" && typeof part.toolCallId === "string")
|
|
150
|
+
toolCallIds.push(part.toolCallId);
|
|
151
|
+
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
|
152
|
+
const normalized = normalizeText(part.text);
|
|
153
|
+
textWithReasoning += normalized;
|
|
154
|
+
if (part.type === "text")
|
|
155
|
+
text += normalized;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// A turn carrying only typed reasoning gives no evidence either way.
|
|
159
|
+
if (toolCallIds.length === 0 && !text)
|
|
160
|
+
return undefined;
|
|
161
|
+
if (toolCallIds.some((id) => entry.toolCallIds.includes(id)))
|
|
162
|
+
return undefined;
|
|
163
|
+
// Compare against the latest step only: a foreign "Done." must not match an
|
|
164
|
+
// older Cursor turn that happened to contain the same words. Same model: the
|
|
165
|
+
// host keeps our reasoning typed, so its text parts match our text. After a
|
|
166
|
+
// model switch OpenCode 1.x replays that reasoning as text parts in place,
|
|
167
|
+
// so the turn matches our reasoning and text in emission order instead.
|
|
168
|
+
if (entry.text && text.slice(0, MAX_TEXT_CHARS) === entry.text)
|
|
169
|
+
return undefined;
|
|
170
|
+
if (entry.textWithReasoning && textWithReasoning.slice(0, MAX_TEXT_CHARS) === entry.textWithReasoning) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
return "foreign-assistant";
|
|
174
|
+
}
|
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).
|
|
@@ -140,12 +143,36 @@ export type CursorSession = {
|
|
|
140
143
|
hostAgent?: string;
|
|
141
144
|
/** Stable host-system + provider-guidance identity for restart validation. */
|
|
142
145
|
stableSystemPromptHash?: string;
|
|
146
|
+
/** Fresh turn resumed from a stored checkpoint; a blob miss may reseed it. */
|
|
147
|
+
checkpointRebaseEligible?: boolean;
|
|
143
148
|
/** Completed compaction must rebase once before resuming a normal agent. */
|
|
144
149
|
postCompactionRebase?: boolean;
|
|
145
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Epoch-held host catalog for this OpenCode session. Refreshed on every
|
|
152
|
+
* `doStream` (including held-Run continuation) so exec #36 and permission
|
|
153
|
+
* can see tools that connected after Run open. Also the lifecycle-turn fallback.
|
|
154
|
+
*/
|
|
146
155
|
toolCatalog?: OpencodeToolDef[];
|
|
156
|
+
/** Configured MCP server ids used to split flattened host tool names. */
|
|
157
|
+
knownMcpServers?: string[];
|
|
158
|
+
/** The Run's model accepts images, so held-Run exec results may carry them. */
|
|
159
|
+
supportsImages?: boolean;
|
|
147
160
|
stream: BidiStream;
|
|
148
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>;
|
|
149
176
|
pending: Map<number, PendingExec>;
|
|
150
177
|
/**
|
|
151
178
|
* Cursor display tool calls (tool_call_started) awaiting either an exec or a
|
|
@@ -159,6 +186,11 @@ export type CursorSession = {
|
|
|
159
186
|
* completed list so host todos still receive a replace-all snapshot.
|
|
160
187
|
*/
|
|
161
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>;
|
|
162
194
|
/**
|
|
163
195
|
* Legacy edit calls whose authoritative exec path is still in progress.
|
|
164
196
|
* Cursor implements these as read -> whole-file write; retaining the path
|
|
@@ -294,7 +326,7 @@ export declare class SessionManager {
|
|
|
294
326
|
* Callers must cancel the session heartbeat and wait for the old stream's
|
|
295
327
|
* write chain before this, so an in-flight heartbeat cannot close the session.
|
|
296
328
|
*/
|
|
297
|
-
replaceStream(session: CursorSession, next: BidiStream): void;
|
|
329
|
+
replaceStream(session: CursorSession, next: BidiStream, runId?: string): void;
|
|
298
330
|
private subscribeTerminal;
|
|
299
331
|
private key;
|
|
300
332
|
close(session: CursorSession, reason?: SessionCloseReason, error?: CursorProviderError): void;
|
|
@@ -317,5 +349,15 @@ export declare class SessionManager {
|
|
|
317
349
|
private putTombstone;
|
|
318
350
|
private isTerminalReason;
|
|
319
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
|
+
}>;
|
|
320
362
|
export declare const sessionManager: SessionManager;
|
|
321
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/shared.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export declare const CURSOR_API_HOST = "api2.cursor.sh";
|
|
2
2
|
export declare const CURSOR_WEBSITE_HOST = "cursor.com";
|
|
3
|
+
/**
|
|
4
|
+
* Production OAuth client id Cursor's IDE sends on `POST /oauth/token`
|
|
5
|
+
* (Cursor 3.17.19 `workbench.desktop.main.js`, `cursorCreds.authClientId` for
|
|
6
|
+
* `backendUrl` `https://api2.cursor.sh`). The IDE's non-production id is not
|
|
7
|
+
* used: a custom `CURSOR_API_BASE_URL` keeps this one.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CURSOR_OAUTH_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
|
|
3
10
|
export declare const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
|
|
4
11
|
export declare const CURSOR_PROVIDER_ID = "cursor";
|
|
5
12
|
/** Private provider option injected by the OpenCode plugin for summary turns. */
|
package/dist/shared.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export const CURSOR_API_HOST = "api2.cursor.sh";
|
|
2
2
|
export const CURSOR_WEBSITE_HOST = "cursor.com";
|
|
3
|
+
/**
|
|
4
|
+
* Production OAuth client id Cursor's IDE sends on `POST /oauth/token`
|
|
5
|
+
* (Cursor 3.17.19 `workbench.desktop.main.js`, `cursorCreds.authClientId` for
|
|
6
|
+
* `backendUrl` `https://api2.cursor.sh`). The IDE's non-production id is not
|
|
7
|
+
* used: a custom `CURSOR_API_BASE_URL` keeps this one.
|
|
8
|
+
*/
|
|
9
|
+
export const CURSOR_OAUTH_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
|
|
3
10
|
export const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
|
|
4
11
|
export const CURSOR_PROVIDER_ID = "cursor";
|
|
5
12
|
/** Private provider option injected by the OpenCode plugin for summary turns. */
|
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
|
};
|