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.
Files changed (67) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +28 -9
  3. package/SECURITY.md +43 -7
  4. package/dist/context/build.d.ts +22 -4
  5. package/dist/context/build.js +33 -5
  6. package/dist/context/dynamic-catalog.js +6 -1
  7. package/dist/context/frozen.js +6 -2
  8. package/dist/context/index.d.ts +2 -0
  9. package/dist/context/index.js +2 -0
  10. package/dist/context/paths.d.ts +44 -17
  11. package/dist/context/paths.js +65 -18
  12. package/dist/context/skills-bridge.d.ts +22 -0
  13. package/dist/context/skills-bridge.js +20 -0
  14. package/dist/context/skills.d.ts +34 -0
  15. package/dist/context/skills.js +211 -0
  16. package/dist/host-agent-mode.d.ts +53 -4
  17. package/dist/host-agent-mode.js +117 -13
  18. package/dist/host-plan-file.d.ts +28 -0
  19. package/dist/host-plan-file.js +76 -0
  20. package/dist/image-input.d.ts +18 -0
  21. package/dist/image-input.js +119 -28
  22. package/dist/image-save.d.ts +10 -2
  23. package/dist/image-save.js +33 -21
  24. package/dist/language-model.d.ts +45 -17
  25. package/dist/language-model.js +925 -292
  26. package/dist/model-config.d.ts +9 -0
  27. package/dist/model-config.js +28 -1
  28. package/dist/opencode2/catalog.d.ts +1 -0
  29. package/dist/opencode2/catalog.js +2 -0
  30. package/dist/opencode2/image-save-tool.d.ts +8 -0
  31. package/dist/opencode2/image-save-tool.js +84 -0
  32. package/dist/opencode2/types.d.ts +15 -0
  33. package/dist/parallel-step.d.ts +65 -0
  34. package/dist/parallel-step.js +102 -0
  35. package/dist/plugin-opencode2.js +48 -40
  36. package/dist/plugin.js +69 -18
  37. package/dist/pricing-data.d.ts +14 -1
  38. package/dist/pricing-data.js +14 -1
  39. package/dist/protocol/ask-question.d.ts +12 -1
  40. package/dist/protocol/ask-question.js +77 -3
  41. package/dist/protocol/create-plan.d.ts +47 -25
  42. package/dist/protocol/create-plan.js +33 -23
  43. package/dist/protocol/interactions.d.ts +26 -17
  44. package/dist/protocol/interactions.js +80 -31
  45. package/dist/protocol/messages.js +74 -14
  46. package/dist/protocol/request.d.ts +5 -0
  47. package/dist/protocol/request.js +2 -0
  48. package/dist/protocol/switch-mode.d.ts +66 -27
  49. package/dist/protocol/switch-mode.js +147 -73
  50. package/dist/protocol/token-details.d.ts +2 -0
  51. package/dist/protocol/token-details.js +11 -1
  52. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  53. package/dist/protocol/tool-call-bridge.js +14 -0
  54. package/dist/protocol/tools.d.ts +16 -1
  55. package/dist/protocol/tools.js +113 -11
  56. package/dist/replay-safety.d.ts +6 -0
  57. package/dist/replay-safety.js +56 -6
  58. package/dist/session.d.ts +35 -1
  59. package/dist/session.js +45 -1
  60. package/dist/todo-store.d.ts +2 -0
  61. package/dist/todo-store.js +19 -0
  62. package/dist/transport/connect.d.ts +2 -0
  63. package/dist/transport/connect.js +23 -6
  64. package/dist/usage.js +23 -6
  65. package/package.json +3 -2
  66. package/dist/plan-execution-kickoff.d.ts +0 -57
  67. package/dist/plan-execution-kickoff.js +0 -186
@@ -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";
@@ -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)}` || `${baseName} default`;
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;
@@ -36,6 +36,7 @@ export type CatalogModelInfo = {
36
36
  modelID: string;
37
37
  providerID: string;
38
38
  name: string;
39
+ family?: string;
39
40
  capabilities: {
40
41
  tools: boolean;
41
42
  input: string[];
@@ -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
+ }
@@ -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 { cancelPlanExecutionKickoff, createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
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
- const admitPlanKickoff = typeof ctx.session.synthetic === "function"
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
- setPlanExecutionKickoff(async ({ sessionID, planPath }) => {
102
- await switchAgent({ sessionID, agent: "build" });
103
- try {
104
- await admitPlanKickoff({
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: createPlanExecutionKickoffText(planPath),
122
+ text: hostAgentSwitchPromptText(agent),
107
123
  });
108
124
  }
109
- catch (error) {
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
- setPlanExecutionKickoff(undefined);
473
- setHostAgentModeSwitch(undefined);
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
- setPlanExecutionKickoff(typeof promptAsync === "function"
53
- ? async ({ sessionID, planPath }) => {
54
- await promptAsync.call(sessionClient, {
55
- path: { id: sessionID },
56
- body: {
57
- agent: "build",
58
- parts: [{
59
- type: "text",
60
- text: createPlanExecutionKickoffText(planPath),
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.