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/model-config.d.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { type ModelInfo, type ModelVariant } from "./models.js";
|
|
|
8
8
|
* tagged redundantly.
|
|
9
9
|
*/
|
|
10
10
|
export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
|
|
11
|
+
/**
|
|
12
|
+
* models.dev-style family for a Cursor model id: the id without its version
|
|
13
|
+
* segments and context/speed suffixes (`claude-haiku-4-5` → `claude-haiku`,
|
|
14
|
+
* `gemini-3.8-flash` → `gemini-flash`, `gpt-5.6-luna` → `gpt-luna`).
|
|
15
|
+
* Kimi keeps its major generation (`kimi-k2.7-code` → `kimi-k2`). OpenCode
|
|
16
|
+
* picks its small title model by family and falls back to the session model when
|
|
17
|
+
* no entry has one. Cursor Auto (`default`) is not a model family.
|
|
18
|
+
*/
|
|
19
|
+
export declare function cursorModelFamily(id: string): string | undefined;
|
|
11
20
|
export declare function modelInfoToConfig(mi: ModelInfo, options?: {
|
|
12
21
|
thinkingSuffix?: boolean;
|
|
13
22
|
contextTier?: "base" | "long";
|
package/dist/model-config.js
CHANGED
|
@@ -91,7 +91,7 @@ function modelInfoVariants(mi, variants) {
|
|
|
91
91
|
// variant entry indistinguishable from the model entry in pickers that
|
|
92
92
|
// collapse them.
|
|
93
93
|
if (key === baseName && !usedKeys.has(key)) {
|
|
94
|
-
key = `${baseName}${tagDims(v.parameterValues)
|
|
94
|
+
key = `${baseName}${tagDims(v.parameterValues) || " default"}`;
|
|
95
95
|
}
|
|
96
96
|
else if (usedKeys.has(key)) {
|
|
97
97
|
key = `${sanitized}${tagDims(v.parameterValues)}`;
|
|
@@ -140,6 +140,27 @@ export function thinkingSuffixBaseNames(models) {
|
|
|
140
140
|
ambiguous.add(base);
|
|
141
141
|
return ambiguous;
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* models.dev-style family for a Cursor model id: the id without its version
|
|
145
|
+
* segments and context/speed suffixes (`claude-haiku-4-5` → `claude-haiku`,
|
|
146
|
+
* `gemini-3.8-flash` → `gemini-flash`, `gpt-5.6-luna` → `gpt-luna`).
|
|
147
|
+
* Kimi keeps its major generation (`kimi-k2.7-code` → `kimi-k2`). OpenCode
|
|
148
|
+
* picks its small title model by family and falls back to the session model when
|
|
149
|
+
* no entry has one. Cursor Auto (`default`) is not a model family.
|
|
150
|
+
*/
|
|
151
|
+
export function cursorModelFamily(id) {
|
|
152
|
+
if (id === "default")
|
|
153
|
+
return undefined;
|
|
154
|
+
const base = id.replace(/(?:-(?:1m|fast))+$/, "");
|
|
155
|
+
const kimi = /^kimi-(k\d+)(?:[.p]\d+)*(?:-code(?:-highspeed)?)?$/.exec(base);
|
|
156
|
+
if (kimi)
|
|
157
|
+
return `kimi-${kimi[1]}`;
|
|
158
|
+
const family = base
|
|
159
|
+
.split("-")
|
|
160
|
+
.filter((part) => !/^\d+(?:[.p]\d+)*$/.test(part))
|
|
161
|
+
.join("-");
|
|
162
|
+
return family || undefined;
|
|
163
|
+
}
|
|
143
164
|
export function modelInfoToConfig(mi, options = {}) {
|
|
144
165
|
const contextTier = options.contextTier ?? "base";
|
|
145
166
|
const variants = options.variants ?? variantsForTier(mi, contextTier);
|
|
@@ -182,6 +203,12 @@ export function modelInfoToConfig(mi, options = {}) {
|
|
|
182
203
|
output,
|
|
183
204
|
},
|
|
184
205
|
};
|
|
206
|
+
// Optional cache metadata may supply a family; the live AvailableModels
|
|
207
|
+
// schema has no family field. Empty metadata must not block derivation.
|
|
208
|
+
const reported = typeof mi.family === "string" ? mi.family.trim() : "";
|
|
209
|
+
const family = reported || cursorModelFamily(mi.id);
|
|
210
|
+
if (family)
|
|
211
|
+
config.family = family;
|
|
185
212
|
const variantConfig = modelInfoVariants(mi, variants);
|
|
186
213
|
if (variantConfig)
|
|
187
214
|
config.variants = variantConfig;
|
|
@@ -69,6 +69,8 @@ export function modelConfigEntryToInfo(id, entry) {
|
|
|
69
69
|
time: { released: 0 },
|
|
70
70
|
cost: toOpenCode2Costs(entry.cost),
|
|
71
71
|
};
|
|
72
|
+
if (typeof entry.family === "string" && entry.family.trim())
|
|
73
|
+
info.family = entry.family.trim();
|
|
72
74
|
if (options)
|
|
73
75
|
info.settings = { ...options };
|
|
74
76
|
return info;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PluginContext, ToolDraft } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Register the handle-only image commit tool on the OpenCode 2.0 direct
|
|
4
|
+
* catalog. Catalog `permission: "edit"` hides it when the host agent denies
|
|
5
|
+
* edits. Per-call `ask` is used when the runtime context provides it;
|
|
6
|
+
* otherwise containment still gates the write (opaque `image_id` only).
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerCursorImageSaveTool(draft: ToolDraft, ctx: PluginContext): void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { executeCursorImageSave } from "../image-save.js";
|
|
2
|
+
import { cursorImageSaveTool } from "../image-save-tool.js";
|
|
3
|
+
import { CURSOR_IMAGE_SAVE_TOOL } from "../protocol/generate-image.js";
|
|
4
|
+
import { getSessionDirectory } from "../session-directory.js";
|
|
5
|
+
import { hostHasTool, OPENCODE2_DIRECT_TOOL_OPTIONS } from "./todo-tools.js";
|
|
6
|
+
const IMAGE_SAVE_INPUT_SCHEMA = {
|
|
7
|
+
type: "object",
|
|
8
|
+
additionalProperties: false,
|
|
9
|
+
properties: {
|
|
10
|
+
image_id: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Id of the pending Cursor-generated image to save",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
required: ["image_id"],
|
|
16
|
+
};
|
|
17
|
+
const IMAGE_SAVE_OUTPUT_SCHEMA = {
|
|
18
|
+
type: "object",
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
properties: {
|
|
21
|
+
path: { type: "string" },
|
|
22
|
+
bytes: { type: "number" },
|
|
23
|
+
message: { type: "string" },
|
|
24
|
+
},
|
|
25
|
+
required: ["path", "bytes", "message"],
|
|
26
|
+
};
|
|
27
|
+
function toolAsk(context) {
|
|
28
|
+
if (!context || typeof context !== "object")
|
|
29
|
+
return undefined;
|
|
30
|
+
const ask = context.ask;
|
|
31
|
+
return typeof ask === "function"
|
|
32
|
+
? ask
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
async function workspaceForSession(ctx, sessionID) {
|
|
36
|
+
const marked = getSessionDirectory(sessionID);
|
|
37
|
+
if (marked)
|
|
38
|
+
return marked;
|
|
39
|
+
try {
|
|
40
|
+
return (await ctx.session.get({ sessionID })).location.directory;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return ctx.location?.directory || process.cwd();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Register the handle-only image commit tool on the OpenCode 2.0 direct
|
|
48
|
+
* catalog. Catalog `permission: "edit"` hides it when the host agent denies
|
|
49
|
+
* edits. Per-call `ask` is used when the runtime context provides it;
|
|
50
|
+
* otherwise containment still gates the write (opaque `image_id` only).
|
|
51
|
+
*/
|
|
52
|
+
export function registerCursorImageSaveTool(draft, ctx) {
|
|
53
|
+
if (hostHasTool(draft, CURSOR_IMAGE_SAVE_TOOL))
|
|
54
|
+
return;
|
|
55
|
+
draft.add({
|
|
56
|
+
name: CURSOR_IMAGE_SAVE_TOOL,
|
|
57
|
+
description: cursorImageSaveTool.description,
|
|
58
|
+
input: IMAGE_SAVE_INPUT_SCHEMA,
|
|
59
|
+
output: IMAGE_SAVE_OUTPUT_SCHEMA,
|
|
60
|
+
options: { ...OPENCODE2_DIRECT_TOOL_OPTIONS, permission: "edit" },
|
|
61
|
+
execute: async (input, context) => {
|
|
62
|
+
const sessionID = context.sessionID;
|
|
63
|
+
if (typeof sessionID !== "string" || !sessionID) {
|
|
64
|
+
throw new Error("OpenCode 2.0 image-save context did not provide a sessionID");
|
|
65
|
+
}
|
|
66
|
+
const directory = await workspaceForSession(ctx, sessionID);
|
|
67
|
+
const result = await executeCursorImageSave({ image_id: input?.image_id }, {
|
|
68
|
+
worktree: directory,
|
|
69
|
+
directory,
|
|
70
|
+
// The public 2.0 ToolContext has no permission prompt; catalog
|
|
71
|
+
// `permission: "edit"` and containment gate the write instead.
|
|
72
|
+
ask: toolAsk(context) ?? null,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
output: {
|
|
76
|
+
path: result.title,
|
|
77
|
+
bytes: result.bytes,
|
|
78
|
+
message: result.output,
|
|
79
|
+
},
|
|
80
|
+
content: result.output,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -43,6 +43,7 @@ export type ModelInfo2 = {
|
|
|
43
43
|
modelID: string;
|
|
44
44
|
providerID: string;
|
|
45
45
|
name: string;
|
|
46
|
+
family?: string;
|
|
46
47
|
capabilities: {
|
|
47
48
|
tools: boolean;
|
|
48
49
|
input: readonly string[];
|
|
@@ -415,6 +416,19 @@ export type McpEditor = {
|
|
|
415
416
|
export type McpDomain = {
|
|
416
417
|
readonly transform: Transform<McpEditor>;
|
|
417
418
|
};
|
|
419
|
+
/** Only the fields this plugin reads from a registered skill. */
|
|
420
|
+
export type SkillInfo = {
|
|
421
|
+
readonly id: string;
|
|
422
|
+
readonly path: string;
|
|
423
|
+
};
|
|
424
|
+
export type SkillDomain = {
|
|
425
|
+
readonly list: () => Promise<{
|
|
426
|
+
readonly location: {
|
|
427
|
+
readonly directory: string;
|
|
428
|
+
};
|
|
429
|
+
readonly data: readonly SkillInfo[];
|
|
430
|
+
}>;
|
|
431
|
+
};
|
|
418
432
|
export type PluginLocation = {
|
|
419
433
|
readonly directory: string;
|
|
420
434
|
};
|
|
@@ -429,6 +443,7 @@ export type PluginContext = {
|
|
|
429
443
|
readonly shell?: ShellDomain;
|
|
430
444
|
readonly websearch?: WebSearchDomain;
|
|
431
445
|
readonly mcp?: McpDomain;
|
|
446
|
+
readonly skill?: SkillDomain;
|
|
432
447
|
};
|
|
433
448
|
export type Cleanup = () => Promise<void> | void;
|
|
434
449
|
export type Plugin2 = {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel tool-call step boundary for OpenCode's AI SDK step model.
|
|
3
|
+
*
|
|
4
|
+
* Cursor fires execs as they are generated; CLI demuxes them concurrently and
|
|
5
|
+
* has no step boundary. OpenCode needs one `finish: tool-calls` after every
|
|
6
|
+
* call of the generation. Cursor's `tool_requests_listed` (InteractionUpdate
|
|
7
|
+
* field 27) carries that count. See tasks/plans/parallel-tool-calls-cli-parity.md.
|
|
8
|
+
*/
|
|
9
|
+
/** Idle without a progress frame while holding → close the step (ms). */
|
|
10
|
+
export declare const PARALLEL_STEP_IDLE_MS = 1500;
|
|
11
|
+
export type ParallelStepState = {
|
|
12
|
+
/** From field 27; undefined until Cursor lists the count for this step. */
|
|
13
|
+
listedCount?: number;
|
|
14
|
+
/** Model tool_call_ids seen this step. */
|
|
15
|
+
callIds: Set<string>;
|
|
16
|
+
/** Ids with a final client disposition this step. */
|
|
17
|
+
resolved: Set<string>;
|
|
18
|
+
/** Host tool-call parts emitted this step. */
|
|
19
|
+
emitted: number;
|
|
20
|
+
/** Wall time of the last progress frame (for the liveness guard). */
|
|
21
|
+
lastProgressAt: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function noteToolRequestsListedSeen(): void;
|
|
24
|
+
export declare function hasSeenToolRequestsListed(): boolean;
|
|
25
|
+
/** Test-only: reset the process capability gate. */
|
|
26
|
+
export declare function resetToolRequestsListedSeenForTests(): void;
|
|
27
|
+
export declare function createParallelStep(now?: number): ParallelStepState;
|
|
28
|
+
export declare function noteParallelStepProgress(step: ParallelStepState, now?: number): void;
|
|
29
|
+
export declare function noteListedCallCount(step: ParallelStepState, callCount: number, now?: number): void;
|
|
30
|
+
/**
|
|
31
|
+
* Record a final disposition for `callId`. Returns true when newly resolved.
|
|
32
|
+
* Empty ids are ignored. Prior-step ids in `prior` are ignored.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveParallelStepCall(step: ParallelStepState, callId: string | undefined, prior?: ReadonlySet<string>, now?: number): boolean;
|
|
35
|
+
export declare function recordParallelStepEmission(step: ParallelStepState, now?: number): void;
|
|
36
|
+
/** True when the listed count is known and every listed call is resolved. */
|
|
37
|
+
export declare function parallelStepCountMet(step: ParallelStepState): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Whether to keep pumping for more calls of this step instead of finishing.
|
|
40
|
+
*
|
|
41
|
+
* - Gate off and no count yet → do not hold (today's one-call finish).
|
|
42
|
+
* - Human-gated interaction with unknown count → do not hold (prompt must show).
|
|
43
|
+
* - Otherwise hold until the count is met (or the liveness guard fires).
|
|
44
|
+
*/
|
|
45
|
+
export declare function shouldHoldParallelStep(step: ParallelStepState, options?: {
|
|
46
|
+
fieldSeen?: boolean;
|
|
47
|
+
humanGatedWithoutCount?: boolean;
|
|
48
|
+
}): boolean;
|
|
49
|
+
/** True when the hold has made no progress for `idleMs`. */
|
|
50
|
+
export declare function shouldGuardCloseParallelStep(step: ParallelStepState, now?: number, idleMs?: number): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Frames that reset the liveness guard while holding. Heartbeat, KV, and
|
|
53
|
+
* checkpoint are not progress — they say nothing about further tool calls.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isParallelStepProgressFrame(kinds: {
|
|
56
|
+
toolRequestsListed?: boolean;
|
|
57
|
+
partialToolCall?: boolean;
|
|
58
|
+
toolCallDelta?: boolean;
|
|
59
|
+
toolCallStarted?: boolean;
|
|
60
|
+
toolCallCompleted?: boolean;
|
|
61
|
+
exec?: boolean;
|
|
62
|
+
interactionQuery?: boolean;
|
|
63
|
+
textDelta?: boolean;
|
|
64
|
+
thinkingDelta?: boolean;
|
|
65
|
+
}): boolean;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel tool-call step boundary for OpenCode's AI SDK step model.
|
|
3
|
+
*
|
|
4
|
+
* Cursor fires execs as they are generated; CLI demuxes them concurrently and
|
|
5
|
+
* has no step boundary. OpenCode needs one `finish: tool-calls` after every
|
|
6
|
+
* call of the generation. Cursor's `tool_requests_listed` (InteractionUpdate
|
|
7
|
+
* field 27) carries that count. See tasks/plans/parallel-tool-calls-cli-parity.md.
|
|
8
|
+
*/
|
|
9
|
+
/** Idle without a progress frame while holding → close the step (ms). */
|
|
10
|
+
export const PARALLEL_STEP_IDLE_MS = 1_500;
|
|
11
|
+
/** Process-level: Cursor has sent field 27 on any Run in this process. */
|
|
12
|
+
let toolRequestsListedSeen = false;
|
|
13
|
+
export function noteToolRequestsListedSeen() {
|
|
14
|
+
toolRequestsListedSeen = true;
|
|
15
|
+
}
|
|
16
|
+
export function hasSeenToolRequestsListed() {
|
|
17
|
+
return toolRequestsListedSeen;
|
|
18
|
+
}
|
|
19
|
+
/** Test-only: reset the process capability gate. */
|
|
20
|
+
export function resetToolRequestsListedSeenForTests() {
|
|
21
|
+
toolRequestsListedSeen = false;
|
|
22
|
+
}
|
|
23
|
+
export function createParallelStep(now = Date.now()) {
|
|
24
|
+
return {
|
|
25
|
+
callIds: new Set(),
|
|
26
|
+
resolved: new Set(),
|
|
27
|
+
emitted: 0,
|
|
28
|
+
lastProgressAt: now,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function noteParallelStepProgress(step, now = Date.now()) {
|
|
32
|
+
step.lastProgressAt = now;
|
|
33
|
+
}
|
|
34
|
+
export function noteListedCallCount(step, callCount, now = Date.now()) {
|
|
35
|
+
noteToolRequestsListedSeen();
|
|
36
|
+
if (Number.isFinite(callCount) && callCount >= 0) {
|
|
37
|
+
step.listedCount = callCount;
|
|
38
|
+
}
|
|
39
|
+
noteParallelStepProgress(step, now);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Record a final disposition for `callId`. Returns true when newly resolved.
|
|
43
|
+
* Empty ids are ignored. Prior-step ids in `prior` are ignored.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveParallelStepCall(step, callId, prior, now = Date.now()) {
|
|
46
|
+
if (!callId)
|
|
47
|
+
return false;
|
|
48
|
+
if (prior?.has(callId))
|
|
49
|
+
return false;
|
|
50
|
+
step.callIds.add(callId);
|
|
51
|
+
if (step.resolved.has(callId))
|
|
52
|
+
return false;
|
|
53
|
+
step.resolved.add(callId);
|
|
54
|
+
noteParallelStepProgress(step, now);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
export function recordParallelStepEmission(step, now = Date.now()) {
|
|
58
|
+
step.emitted++;
|
|
59
|
+
noteParallelStepProgress(step, now);
|
|
60
|
+
}
|
|
61
|
+
/** True when the listed count is known and every listed call is resolved. */
|
|
62
|
+
export function parallelStepCountMet(step) {
|
|
63
|
+
return step.listedCount !== undefined && step.resolved.size >= step.listedCount;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether to keep pumping for more calls of this step instead of finishing.
|
|
67
|
+
*
|
|
68
|
+
* - Gate off and no count yet → do not hold (today's one-call finish).
|
|
69
|
+
* - Human-gated interaction with unknown count → do not hold (prompt must show).
|
|
70
|
+
* - Otherwise hold until the count is met (or the liveness guard fires).
|
|
71
|
+
*/
|
|
72
|
+
export function shouldHoldParallelStep(step, options = {}) {
|
|
73
|
+
if (options.humanGatedWithoutCount && step.listedCount === undefined)
|
|
74
|
+
return false;
|
|
75
|
+
const fieldSeen = options.fieldSeen ?? hasSeenToolRequestsListed();
|
|
76
|
+
if (!fieldSeen && step.listedCount === undefined)
|
|
77
|
+
return false;
|
|
78
|
+
if (parallelStepCountMet(step))
|
|
79
|
+
return false;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/** True when the hold has made no progress for `idleMs`. */
|
|
83
|
+
export function shouldGuardCloseParallelStep(step, now = Date.now(), idleMs = PARALLEL_STEP_IDLE_MS) {
|
|
84
|
+
if (parallelStepCountMet(step))
|
|
85
|
+
return false;
|
|
86
|
+
return now - step.lastProgressAt >= idleMs;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Frames that reset the liveness guard while holding. Heartbeat, KV, and
|
|
90
|
+
* checkpoint are not progress — they say nothing about further tool calls.
|
|
91
|
+
*/
|
|
92
|
+
export function isParallelStepProgressFrame(kinds) {
|
|
93
|
+
return !!(kinds.toolRequestsListed
|
|
94
|
+
|| kinds.partialToolCall
|
|
95
|
+
|| kinds.toolCallDelta
|
|
96
|
+
|| kinds.toolCallStarted
|
|
97
|
+
|| kinds.toolCallCompleted
|
|
98
|
+
|| kinds.exec
|
|
99
|
+
|| kinds.interactionQuery
|
|
100
|
+
|| kinds.textDelta
|
|
101
|
+
|| kinds.thinkingDelta);
|
|
102
|
+
}
|
package/dist/plugin-opencode2.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CURSOR_PROVIDER_ID, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, } from "./shared.js";
|
|
2
2
|
import { createSdk, cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled, isCursorPackage } from "./plugin-core.js";
|
|
3
|
-
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
3
|
+
import { opencode2PlanDir, opencodeGlobalCacheDir, setNativePlansDir } from "./context/paths.js";
|
|
4
4
|
import { discoverModels, isCacheFresh, readCache } from "./models.js";
|
|
5
5
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
6
6
|
import { sessionActivity } from "./activity.js";
|
|
@@ -10,13 +10,14 @@ import { applyCursorProviderInventory, CURSOR_INTEGRATION_ID } from "./opencode2
|
|
|
10
10
|
import { applyCursorIntegration, requireCursorAccessToken, resolveCursorAccessToken, } from "./opencode2/integration.js";
|
|
11
11
|
import { exposeDirectMcpTools, rememberDirectMcpNamespaces } from "./opencode2/mcp-direct.js";
|
|
12
12
|
import { registerTodoTools } from "./opencode2/todo-tools.js";
|
|
13
|
+
import { registerCursorImageSaveTool } from "./opencode2/image-save-tool.js";
|
|
13
14
|
import { OPENCODE_2_TOOL_DIALECT } from "./protocol/tools.js";
|
|
14
15
|
import { clearSessionTodos } from "./todo-store.js";
|
|
15
16
|
import { markCompactionSession } from "./compaction-marker.js";
|
|
16
17
|
import { getSessionDirectory, markSessionDirectory } from "./session-directory.js";
|
|
18
|
+
import { rememberHostSkillFiles } from "./context/skills.js";
|
|
17
19
|
import { trace } from "./debug.js";
|
|
18
|
-
import {
|
|
19
|
-
import { cancelHostAgentModeSwitch, setHostAgentModeSwitch, } from "./host-agent-mode.js";
|
|
20
|
+
import { cancelHostAgentModeSwitch, hostAgentSwitchPromptText, setHostAgentModeSwitch, } from "./host-agent-mode.js";
|
|
20
21
|
import { clearActiveCursorMode, getActiveCursorMode, normalizeSwitchModeId, setActiveCursorMode, } from "./protocol/switch-mode.js";
|
|
21
22
|
import { CursorPlugin } from "./plugin.js";
|
|
22
23
|
/**
|
|
@@ -92,45 +93,36 @@ const plugin = {
|
|
|
92
93
|
setup: async (ctx) => {
|
|
93
94
|
const cacheDir = opencodeGlobalCacheDir();
|
|
94
95
|
const workspaceRoot = ctx.location?.directory || process.cwd();
|
|
96
|
+
// CreatePlan writes where OpenCode 2.0's Plan agent keeps plan files.
|
|
97
|
+
// OpenCode 2.0 sets a plugin up once per location instance and again on a
|
|
98
|
+
// plugin reload, then disposes older setups: each cleanup removes only
|
|
99
|
+
// what its own setup installed.
|
|
100
|
+
const disposeNativePlansDir = setNativePlansDir(opencode2PlanDir());
|
|
101
|
+
let disposeHostAgentModeSwitch;
|
|
102
|
+
trace(`opencode2 plugin: setup directory=${workspaceRoot}`);
|
|
95
103
|
const hasShellEnvHook = typeof ctx.shell?.hook === "function";
|
|
96
|
-
|
|
97
|
-
? ctx.session.synthetic
|
|
98
|
-
: ctx.session.prompt;
|
|
99
|
-
if (typeof ctx.session.switchAgent === "function" && typeof admitPlanKickoff === "function") {
|
|
104
|
+
if (typeof ctx.session.switchAgent === "function") {
|
|
100
105
|
const switchAgent = ctx.session.switchAgent;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
const continueTurn = typeof ctx.session.synthetic === "function"
|
|
107
|
+
? ctx.session.synthetic
|
|
108
|
+
: typeof ctx.session.prompt === "function"
|
|
109
|
+
? ctx.session.prompt
|
|
110
|
+
: undefined;
|
|
111
|
+
disposeHostAgentModeSwitch = setHostAgentModeSwitch(async ({ sessionID, targetModeID }) => {
|
|
112
|
+
const agent = normalizeSwitchModeId(targetModeID) === "plan"
|
|
113
|
+
|| normalizeSwitchModeId(targetModeID) === "spec"
|
|
114
|
+
? "plan"
|
|
115
|
+
: "build";
|
|
116
|
+
await switchAgent({ sessionID, agent });
|
|
117
|
+
// switchAgent only publishes AgentSelected and leaves the session idle.
|
|
118
|
+
// Continue under the new agent the same way OpenCode 1.x promptAsync does.
|
|
119
|
+
if (continueTurn) {
|
|
120
|
+
await continueTurn({
|
|
105
121
|
sessionID,
|
|
106
|
-
text:
|
|
122
|
+
text: hostAgentSwitchPromptText(agent),
|
|
107
123
|
});
|
|
108
124
|
}
|
|
109
|
-
|
|
110
|
-
// Switching and admitting input are separate public APIs. Restore the
|
|
111
|
-
// plan agent if admission fails so the shared retry state is honest:
|
|
112
|
-
// the plan remains active rather than silently leaving the session in
|
|
113
|
-
// build mode with no execution turn.
|
|
114
|
-
await switchAgent({ sessionID, agent: "plan" }).catch(() => { });
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
setPlanExecutionKickoff(undefined);
|
|
121
|
-
}
|
|
122
|
-
if (typeof ctx.session.switchAgent === "function") {
|
|
123
|
-
const switchAgent = ctx.session.switchAgent;
|
|
124
|
-
setHostAgentModeSwitch(async ({ sessionID, targetModeID }) => {
|
|
125
|
-
const mode = normalizeSwitchModeId(targetModeID);
|
|
126
|
-
await switchAgent({
|
|
127
|
-
sessionID,
|
|
128
|
-
agent: mode === "plan" || mode === "spec" ? "plan" : "build",
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
setHostAgentModeSwitch(undefined);
|
|
125
|
+
}, continueTurn ? { resumesTurn: true } : {});
|
|
134
126
|
}
|
|
135
127
|
const registrations = [];
|
|
136
128
|
const track = async (p) => {
|
|
@@ -262,6 +254,7 @@ const plugin = {
|
|
|
262
254
|
// them as direct catalog tools (`codemode: false` + output schema) if
|
|
263
255
|
// the host does not already own those names. When off, register none.
|
|
264
256
|
registerTodoTools(draft);
|
|
257
|
+
registerCursorImageSaveTool(draft, ctx);
|
|
265
258
|
// MCP tools default into Code Mode. Move every server that did not
|
|
266
259
|
// explicitly opt in onto the direct catalog so this provider can
|
|
267
260
|
// advertise them to Cursor (issue #29 still routes via CallDynamicTool).
|
|
@@ -375,6 +368,20 @@ const plugin = {
|
|
|
375
368
|
// last known session directory ahead of this Location's static root.
|
|
376
369
|
// Scoped by the host to this provider: other providers' requests never
|
|
377
370
|
// reach the callback, so the header cannot leak to their endpoints.
|
|
371
|
+
// Skill files for RequestContext path-desc `agent_skills` (see context/skills.ts).
|
|
372
|
+
const rememberSkillFiles = async () => {
|
|
373
|
+
if (!ctx.skill)
|
|
374
|
+
return;
|
|
375
|
+
try {
|
|
376
|
+
const listed = await ctx.skill.list();
|
|
377
|
+
// skill.list is bound to this plugin Location. A session may have
|
|
378
|
+
// moved; never relabel this catalog with the session's new directory.
|
|
379
|
+
rememberHostSkillFiles(listed.location.directory, listed.data);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
trace(`model.request: skill.list failed: ${String(error)}`);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
378
385
|
await track(ctx.session.hook("model.request", async (event) => {
|
|
379
386
|
const current = await ctx.session
|
|
380
387
|
.get({ sessionID: event.sessionID })
|
|
@@ -387,6 +394,7 @@ const plugin = {
|
|
|
387
394
|
const directory = current ?? getSessionDirectory(event.sessionID) ?? ctx.location?.directory;
|
|
388
395
|
if (!directory)
|
|
389
396
|
return;
|
|
397
|
+
await rememberSkillFiles();
|
|
390
398
|
event.headers = {
|
|
391
399
|
...event.headers,
|
|
392
400
|
"x-opencode-directory": encodeURIComponent(directory),
|
|
@@ -469,8 +477,9 @@ const plugin = {
|
|
|
469
477
|
return async () => {
|
|
470
478
|
clearInterval(retry);
|
|
471
479
|
unsubscribe?.();
|
|
472
|
-
|
|
473
|
-
|
|
480
|
+
disposeHostAgentModeSwitch?.();
|
|
481
|
+
disposeNativePlansDir();
|
|
482
|
+
trace(`opencode2 plugin: cleanup directory=${workspaceRoot}`);
|
|
474
483
|
for (const registration of registrations.reverse()) {
|
|
475
484
|
await registration.dispose().catch(() => { });
|
|
476
485
|
}
|
|
@@ -529,7 +538,6 @@ function applySessionActivity(event, onCredentialSwitch) {
|
|
|
529
538
|
sessionActivity.removeSession(id);
|
|
530
539
|
clearSessionTodos(id);
|
|
531
540
|
clearActiveCursorMode(id);
|
|
532
|
-
cancelPlanExecutionKickoff(id);
|
|
533
541
|
cancelHostAgentModeSwitch(id);
|
|
534
542
|
}
|
|
535
543
|
break;
|
package/dist/plugin.js
CHANGED
|
@@ -12,8 +12,9 @@ import { readStoredAuth } from "./context/auth-store.js";
|
|
|
12
12
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
13
13
|
import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, setCursorShellPath, } from "./shell-timeout.js";
|
|
14
14
|
import { sessionActivity } from "./activity.js";
|
|
15
|
-
import { createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
|
|
16
15
|
import { dispatchHostEventBridge } from "./host-event-bridge.js";
|
|
16
|
+
import { createPromptHostAgentModeSwitch, setHostAgentModeSwitch } from "./host-agent-mode.js";
|
|
17
|
+
import { isHostPlanFileResolved, resolveHostPlanFile } from "./host-plan-file.js";
|
|
17
18
|
const MODULE_URL = new URL("./index.js", import.meta.url).href;
|
|
18
19
|
/**
|
|
19
20
|
* Raw `crsr_` key behind an API-key login: under `metadata.apiKey` (saved at
|
|
@@ -41,29 +42,63 @@ function isSameCredential(latest, started) {
|
|
|
41
42
|
}
|
|
42
43
|
return false;
|
|
43
44
|
}
|
|
45
|
+
/** Names of the host's own user-selectable primary agents, from `app.agents()`. */
|
|
46
|
+
function primaryAgentNames(response) {
|
|
47
|
+
const list = Array.isArray(response)
|
|
48
|
+
? response
|
|
49
|
+
: Array.isArray(response?.data)
|
|
50
|
+
? response.data
|
|
51
|
+
: undefined;
|
|
52
|
+
if (!list)
|
|
53
|
+
return undefined;
|
|
54
|
+
const names = new Set();
|
|
55
|
+
for (const item of list) {
|
|
56
|
+
if (typeof item?.name !== "string" || item.mode === "subagent" || item.hidden === true)
|
|
57
|
+
continue;
|
|
58
|
+
names.add(item.name);
|
|
59
|
+
}
|
|
60
|
+
return names;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Install the OpenCode 1.x host-agent switch. Returns the agent-list refresh,
|
|
64
|
+
* which the caller runs once the host serves requests: the list is read from
|
|
65
|
+
* the host's own API, and a failed read leaves SwitchMode provider-owned.
|
|
66
|
+
*/
|
|
67
|
+
function installPromptHostAgentModeSwitch(input, sessionClient, promptAsync) {
|
|
68
|
+
const app = input.client?.app;
|
|
69
|
+
const agents = app?.agents;
|
|
70
|
+
// Nothing to install; a switch another entrypoint installed stays in place.
|
|
71
|
+
if (typeof promptAsync !== "function" || typeof agents !== "function")
|
|
72
|
+
return undefined;
|
|
73
|
+
let primary;
|
|
74
|
+
const refresh = () => agents.call(app)
|
|
75
|
+
.then((response) => { primary = primaryAgentNames(response) ?? primary; })
|
|
76
|
+
.catch((error) => trace(`host-agent-mode: agent list unavailable: ${errorMessage(error)}`));
|
|
77
|
+
const { apply, accepts } = createPromptHostAgentModeSwitch(async ({ sessionID, agent, text }) => {
|
|
78
|
+
await promptAsync.call(sessionClient, {
|
|
79
|
+
path: { id: sessionID },
|
|
80
|
+
body: { agent, parts: [{ type: "text", text, synthetic: true }] },
|
|
81
|
+
});
|
|
82
|
+
void refresh();
|
|
83
|
+
}, () => primary);
|
|
84
|
+
setHostAgentModeSwitch(apply, { resumesTurn: true, accepts });
|
|
85
|
+
return refresh;
|
|
86
|
+
}
|
|
44
87
|
export async function CursorPlugin(input) {
|
|
45
88
|
const cacheDir = opencodeGlobalCacheDir();
|
|
46
89
|
const apiBaseURL = cursorApiBaseURL();
|
|
47
90
|
const classicTools = await loadClassicTools();
|
|
48
|
-
// Install the OpenCode plan-exit-shaped kickoff only when the OpenCode client
|
|
49
|
-
// exposes that structural API. Without it, no synthetic kickoff is registered.
|
|
50
91
|
const sessionClient = input.client?.session;
|
|
51
92
|
const promptAsync = sessionClient?.promptAsync;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
synthetic: true,
|
|
62
|
-
}],
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
: undefined);
|
|
93
|
+
// Cursor SwitchMode without an advertised plan_enter selects the host's
|
|
94
|
+
// primary agent the OpenCode 1.x way once its Run has ended. The OpenCode 2.0
|
|
95
|
+
// entrypoint replaces this with `session.switchAgent` in its own setup().
|
|
96
|
+
const refreshHostAgents = installPromptHostAgentModeSwitch(input, sessionClient, promptAsync);
|
|
97
|
+
let hostAgentsLoaded;
|
|
98
|
+
const sessionGet = sessionClient?.get;
|
|
99
|
+
const getSession = typeof sessionGet === "function"
|
|
100
|
+
? (sessionID) => sessionGet.call(sessionClient, { path: { id: sessionID } })
|
|
101
|
+
: undefined;
|
|
67
102
|
let lastPersistAttempt;
|
|
68
103
|
async function persistAuth(body) {
|
|
69
104
|
await input.client.auth.set({
|
|
@@ -280,6 +315,22 @@ export async function CursorPlugin(input) {
|
|
|
280
315
|
// Carry the canonical OpenCode id so an incompatible Cursor checkpoint
|
|
281
316
|
// is rotated instead of resuming the prior agent's prompt.
|
|
282
317
|
output.options[CURSOR_HOST_AGENT_OPTION] = hookInput.agent;
|
|
318
|
+
// First Cursor request: the host now serves its API, so read its agents
|
|
319
|
+
// before any Run can raise SwitchMode. Concurrent first requests share
|
|
320
|
+
// the one read; later requests find it settled.
|
|
321
|
+
hostAgentsLoaded ??= refreshHostAgents?.();
|
|
322
|
+
await hostAgentsLoaded;
|
|
323
|
+
// Know the session's own plan file (OpenCode's `Session.plan`) before the
|
|
324
|
+
// Run starts, so CreatePlan records the plan where the plan agent and
|
|
325
|
+
// plan_exit read it. It is fixed per session, so resolve it once.
|
|
326
|
+
if (hookInput.agent !== "compaction" && getSession && !isHostPlanFileResolved(hookInput.sessionID)) {
|
|
327
|
+
await resolveHostPlanFile({
|
|
328
|
+
sessionID: hookInput.sessionID,
|
|
329
|
+
getSession,
|
|
330
|
+
worktree: input.worktree || input.project?.worktree,
|
|
331
|
+
vcs: !!input.project?.vcs,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
283
334
|
// OpenCode's compaction pipeline invokes the LLM with agent="compaction".
|
|
284
335
|
// Carry that stable runtime fact into LanguageModelV3 providerOptions so
|
|
285
336
|
// the provider never has to guess from an empty tool list.
|