cursor-opencode-provider 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
@@ -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,24 +1,28 @@
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
- import { sessionActivity } from "./activity.js";
6
+ import { applyHostPromptEvent, applyHostToolEvent, sessionActivity } from "./activity.js";
7
+ import { notifyHostInterrupt } from "./host-interrupt.js";
8
+ import { announceHostSteer, forgetEarlySteerOfInbox, forgetEarlySteers, rememberQueuedSteer, takeQueuedSteer, } from "./host-steer.js";
7
9
  import { fetchOpenCodeWebSearchText, parseExaWebSearchResults, } from "./web-tools.js";
8
- import { captureCursorShellResult, cursorShellEnvForCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
10
+ import { armCursorShellCreate, captureCursorShellResult, captureCursorShellFailure, prepareCursorShellCreate, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
9
11
  import { applyCursorProviderInventory, CURSOR_INTEGRATION_ID } from "./opencode2/catalog.js";
10
12
  import { applyCursorIntegration, requireCursorAccessToken, resolveCursorAccessToken, } from "./opencode2/integration.js";
11
13
  import { exposeDirectMcpTools, rememberDirectMcpNamespaces } from "./opencode2/mcp-direct.js";
12
14
  import { registerTodoTools } from "./opencode2/todo-tools.js";
15
+ import { registerCursorImageSaveTool } from "./opencode2/image-save-tool.js";
13
16
  import { OPENCODE_2_TOOL_DIALECT } from "./protocol/tools.js";
14
17
  import { clearSessionTodos } from "./todo-store.js";
15
18
  import { markCompactionSession } from "./compaction-marker.js";
16
19
  import { getSessionDirectory, markSessionDirectory } from "./session-directory.js";
20
+ import { rememberHostSkillFiles } from "./context/skills.js";
17
21
  import { trace } from "./debug.js";
18
- import { cancelPlanExecutionKickoff, createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
19
- import { cancelHostAgentModeSwitch, setHostAgentModeSwitch, } from "./host-agent-mode.js";
22
+ import { cancelHostAgentModeSwitch, hostAgentSwitchPromptText, setHostAgentModeSwitch, } from "./host-agent-mode.js";
20
23
  import { clearActiveCursorMode, getActiveCursorMode, normalizeSwitchModeId, setActiveCursorMode, } from "./protocol/switch-mode.js";
21
24
  import { CursorPlugin } from "./plugin.js";
25
+ import { nativeToolRefusal, preserveNativeToolResults } from "./opencode2/tool-results.js";
22
26
  /**
23
27
  * OpenCode 2.0 plugin.
24
28
  *
@@ -92,45 +96,90 @@ const plugin = {
92
96
  setup: async (ctx) => {
93
97
  const cacheDir = opencodeGlobalCacheDir();
94
98
  const workspaceRoot = ctx.location?.directory || process.cwd();
99
+ // CreatePlan writes where OpenCode 2.0's Plan agent keeps plan files.
100
+ // OpenCode 2.0 sets a plugin up once per location instance and again on a
101
+ // plugin reload, then disposes older setups: each cleanup removes only
102
+ // what its own setup installed.
103
+ const disposeNativePlansDir = setNativePlansDir(opencode2PlanDir());
104
+ let disposeHostAgentModeSwitch;
105
+ trace(`opencode2 plugin: setup directory=${workspaceRoot}`);
95
106
  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") {
107
+ const refusals = new Map();
108
+ const permissionCalls = new Map();
109
+ const rememberRefusal = (id, refusal) => {
110
+ refusals.delete(id);
111
+ refusals.set(id, refusal);
112
+ while (refusals.size > 512)
113
+ refusals.delete(refusals.keys().next().value);
114
+ };
115
+ const releasePermissionCalls = () => {
116
+ for (const call of permissionCalls.values())
117
+ releaseCursorShellEnv(call.callID);
118
+ permissionCalls.clear();
119
+ };
120
+ const toolLifecycle = (event) => {
121
+ const payload = eventPayload(event);
122
+ if (event?.type === "permission.asked" && payload?.source?.type === "tool"
123
+ && typeof payload.source.id === "string" && payload.source.id.startsWith("cursor_")
124
+ && typeof payload.id === "string" && typeof payload.sessionID === "string") {
125
+ permissionCalls.set(payload.id, { sessionID: payload.sessionID, callID: payload.source.id });
126
+ }
127
+ else if (event?.type === "permission.replied") {
128
+ const call = permissionCalls.get(payload?.requestID);
129
+ if (!call || call.sessionID !== payload?.sessionID)
130
+ return;
131
+ permissionCalls.delete(payload.requestID);
132
+ if (payload.reply === "reject") {
133
+ rememberRefusal(call.callID, "rejected");
134
+ releaseCursorShellEnv(call.callID);
135
+ // Native rejection also rejects the session's other pending requests.
136
+ for (const [id, pending] of permissionCalls)
137
+ if (pending.sessionID === call.sessionID) {
138
+ rememberRefusal(pending.callID, "rejected");
139
+ releaseCursorShellEnv(pending.callID);
140
+ permissionCalls.delete(id);
141
+ }
142
+ }
143
+ }
144
+ else if ((event?.type === "session.tool.failed" || event?.type === "session.tool.success")
145
+ && typeof payload?.id === "string") {
146
+ if (event.type === "session.tool.failed")
147
+ releaseCursorShellEnv(payload.id);
148
+ for (const [id, call] of permissionCalls)
149
+ if (call.callID === payload.id && call.sessionID === payload.sessionID) {
150
+ permissionCalls.delete(id);
151
+ }
152
+ }
153
+ else if (event?.type === "session.deleted") {
154
+ for (const [id, call] of permissionCalls)
155
+ if (call.sessionID === payload?.sessionID) {
156
+ releaseCursorShellEnv(call.callID);
157
+ permissionCalls.delete(id);
158
+ }
159
+ }
160
+ };
161
+ if (typeof ctx.session.switchAgent === "function") {
100
162
  const switchAgent = ctx.session.switchAgent;
101
- setPlanExecutionKickoff(async ({ sessionID, planPath }) => {
102
- await switchAgent({ sessionID, agent: "build" });
103
- try {
104
- await admitPlanKickoff({
163
+ const continueTurn = typeof ctx.session.synthetic === "function"
164
+ ? ctx.session.synthetic
165
+ : typeof ctx.session.prompt === "function"
166
+ ? ctx.session.prompt
167
+ : undefined;
168
+ disposeHostAgentModeSwitch = setHostAgentModeSwitch(async ({ sessionID, targetModeID }) => {
169
+ const agent = normalizeSwitchModeId(targetModeID) === "plan"
170
+ || normalizeSwitchModeId(targetModeID) === "spec"
171
+ ? "plan"
172
+ : "build";
173
+ await switchAgent({ sessionID, agent });
174
+ // switchAgent only publishes AgentSelected and leaves the session idle.
175
+ // Continue under the new agent the same way OpenCode 1.x promptAsync does.
176
+ if (continueTurn) {
177
+ await continueTurn({
105
178
  sessionID,
106
- text: createPlanExecutionKickoffText(planPath),
179
+ text: hostAgentSwitchPromptText(agent),
107
180
  });
108
181
  }
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);
182
+ }, continueTurn ? { resumesTurn: true } : {});
134
183
  }
135
184
  const registrations = [];
136
185
  const track = async (p) => {
@@ -200,15 +249,13 @@ const plugin = {
200
249
  };
201
250
  // ── AI SDK wiring ────────────────────────────────────────
202
251
  await track(ctx.aisdk.hook("sdk", async (event) => {
203
- if (event.sdk)
204
- return;
205
252
  if (!isCursorPackage(event.package, event.model.providerID))
206
253
  return;
254
+ // The host's generic loader runs before plugin hooks and may already
255
+ // have created a Cursor SDK with a captured token. Own our package's
256
+ // SDK so current integration credentials are resolved on every Run.
207
257
  event.sdk = createSdk({
208
258
  name: event.model.providerID || CURSOR_PROVIDER_ID,
209
- // Resolved per Run open: this SDK lives as long as the daemon, so
210
- // a token captured here would outlive its expiry.
211
- getAccessToken: (request) => requireCursorAccessToken(ctx.integration, request),
212
259
  // Static fallback only. This hook fires once per model/package, not
213
260
  // per session, and 2.0 runs one daemon across many projects — the
214
261
  // real per-request directory comes from `x-opencode-directory`
@@ -216,6 +263,9 @@ const plugin = {
216
263
  workspaceRoot,
217
264
  cacheDir,
218
265
  ...event.options,
266
+ // Keep after host options: a captured resolver/token must not bypass
267
+ // the integration's current credential and renewal path.
268
+ getAccessToken: (request) => requireCursorAccessToken(ctx.integration, request),
219
269
  // Keep after `event.options` so the OC2 plugin always selects the
220
270
  // `path`/`shell` dialect when advertised schemas are opaque.
221
271
  defaultDialect: OPENCODE_2_TOOL_DIALECT,
@@ -262,6 +312,7 @@ const plugin = {
262
312
  // them as direct catalog tools (`codemode: false` + output schema) if
263
313
  // the host does not already own those names. When off, register none.
264
314
  registerTodoTools(draft);
315
+ registerCursorImageSaveTool(draft, ctx);
265
316
  // MCP tools default into Code Mode. Move every server that did not
266
317
  // explicitly opt in onto the direct catalog so this provider can
267
318
  // advertise them to Cursor (issue #29 still routes via CallDynamicTool).
@@ -269,22 +320,35 @@ const plugin = {
269
320
  exposeDirectMcpTools(draft, directMcpNamespaces);
270
321
  }));
271
322
  // ── Shell timeout wrapper ────────────────────────────────
272
- await track(ctx.tool.hook("execute.before", (event) => {
273
- if (!isShellTool(event.tool))
323
+ await track(ctx.tool.hook("execute.before", async (event) => {
324
+ if (!isShellTool(event.tool) || typeof event.input !== "object" || event.input === null)
274
325
  return;
275
326
  const executionID = toolExecutionID(event);
276
- prepareCursorShellArgs(executionID, event.input, {
277
- // OpenCode 2.0 `shell.create.before` can inject env for bash/zsh.
327
+ const input = event.input;
328
+ prepareCursorShellArgs(executionID, input, {
329
+ deferToShellHook: hasShellEnvHook,
278
330
  preferWrapperCommand: !hasShellEnvHook,
279
331
  });
332
+ if (hasShellEnvHook)
333
+ await armCursorShellCreate(executionID, event);
280
334
  }));
281
335
  await track(ctx.tool.hook("execute.after", (event) => {
336
+ const executionID = toolExecutionID(event);
337
+ if (event.status === "error") {
338
+ const refusal = nativeToolRefusal(event.error);
339
+ if (refusal)
340
+ rememberRefusal(executionID, refusal);
341
+ }
342
+ else
343
+ refusals.delete(executionID);
282
344
  if (!isShellTool(event.tool))
283
345
  return;
284
- const executionID = toolExecutionID(event);
285
346
  try {
286
- if (event.status !== "completed")
347
+ if (event.status !== "completed") {
348
+ if (event.status === "error")
349
+ captureCursorShellFailure(executionID, event.error);
287
350
  return;
351
+ }
288
352
  const result = event.result;
289
353
  // V1: `output` is the model-facing string. V2: `output` is structured
290
354
  // per-tool output (shell: `{ output: string, ... }`) and the
@@ -331,10 +395,7 @@ const plugin = {
331
395
  }));
332
396
  if (ctx.shell) {
333
397
  await track(ctx.shell.hook("create.before", (event) => {
334
- const env = cursorShellEnvForCommand(event.command, event.cwd);
335
- if (!env)
336
- return;
337
- event.env = { ...event.env, ...env };
398
+ prepareCursorShellCreate(event);
338
399
  }));
339
400
  }
340
401
  const rememberSessionDirectory = async (sessionID) => {
@@ -348,6 +409,7 @@ const plugin = {
348
409
  }
349
410
  };
350
411
  await track(ctx.session.hook("context", async (event) => {
412
+ event.messages = preserveNativeToolResults(event.messages, refusals);
351
413
  markCompactionAndOptions(event, event.agent === "compaction");
352
414
  event.options ??= {};
353
415
  event.options[CURSOR_HOST_AGENT_OPTION] = event.agent;
@@ -375,6 +437,20 @@ const plugin = {
375
437
  // last known session directory ahead of this Location's static root.
376
438
  // Scoped by the host to this provider: other providers' requests never
377
439
  // reach the callback, so the header cannot leak to their endpoints.
440
+ // Skill files for RequestContext path-desc `agent_skills` (see context/skills.ts).
441
+ const rememberSkillFiles = async () => {
442
+ if (!ctx.skill)
443
+ return;
444
+ try {
445
+ const listed = await ctx.skill.list();
446
+ // skill.list is bound to this plugin Location. A session may have
447
+ // moved; never relabel this catalog with the session's new directory.
448
+ rememberHostSkillFiles(listed.location.directory, listed.data);
449
+ }
450
+ catch (error) {
451
+ trace(`model.request: skill.list failed: ${String(error)}`);
452
+ }
453
+ };
378
454
  await track(ctx.session.hook("model.request", async (event) => {
379
455
  const current = await ctx.session
380
456
  .get({ sessionID: event.sessionID })
@@ -387,20 +463,24 @@ const plugin = {
387
463
  const directory = current ?? getSessionDirectory(event.sessionID) ?? ctx.location?.directory;
388
464
  if (!directory)
389
465
  return;
466
+ await rememberSkillFiles();
390
467
  event.headers = {
391
468
  ...event.headers,
392
469
  "x-opencode-directory": encodeURIComponent(directory),
393
470
  };
394
471
  }, { providerID: CURSOR_PROVIDER_ID }));
395
472
  await track(ctx.session.hook("compaction", async (event) => {
473
+ event.messages = preserveNativeToolResults(event.messages, refusals);
396
474
  markCompactionAndOptions(event, true);
397
475
  await rememberSessionDirectory(event.sessionID);
398
476
  }));
399
477
  await track(ctx.session.hook("generate", async (event) => {
478
+ event.messages = preserveNativeToolResults(event.messages, refusals);
400
479
  markCompactionAndOptions(event, false);
401
480
  await rememberSessionDirectory(event.sessionID);
402
481
  }));
403
482
  await track(ctx.session.hook("title", async (event) => {
483
+ event.messages = preserveNativeToolResults(event.messages, refusals);
404
484
  markCompactionAndOptions(event, false);
405
485
  await rememberSessionDirectory(event.sessionID);
406
486
  }));
@@ -465,34 +545,61 @@ const plugin = {
465
545
  modelsLoaded = false;
466
546
  credentialGeneration++;
467
547
  };
468
- const unsubscribe = subscribeSessionActivity(ctx, ensureModels, onCredentialSwitch);
548
+ const unsubscribe = subscribeSessionActivity(ctx, ensureModels, onCredentialSwitch, toolLifecycle, releasePermissionCalls);
469
549
  return async () => {
470
550
  clearInterval(retry);
471
551
  unsubscribe?.();
472
- setPlanExecutionKickoff(undefined);
473
- setHostAgentModeSwitch(undefined);
552
+ releasePermissionCalls();
553
+ refusals.clear();
554
+ await saveOpenTurnsForShutdown();
555
+ disposeHostAgentModeSwitch?.();
556
+ disposeNativePlansDir();
557
+ trace(`opencode2 plugin: cleanup directory=${workspaceRoot}`);
474
558
  for (const registration of registrations.reverse()) {
475
559
  await registration.dispose().catch(() => { });
476
560
  }
477
561
  };
478
562
  },
479
563
  };
480
- function subscribeSessionActivity(ctx, onEvent, onCredentialSwitch) {
564
+ const SHUTDOWN_SAVE_TIMEOUT_MS = 2_000;
565
+ /** Save every open turn for the next server start, without holding shutdown up for long. */
566
+ async function saveOpenTurnsForShutdown() {
567
+ let timer;
568
+ const timedOut = new Promise((resolve) => {
569
+ timer = setTimeout(resolve, SHUTDOWN_SAVE_TIMEOUT_MS);
570
+ timer.unref?.();
571
+ });
481
572
  try {
482
- const stream = ctx.event.subscribe();
573
+ await Promise.race([notifyHostInterrupt(undefined, "shutdown"), timedOut]);
574
+ }
575
+ finally {
576
+ clearTimeout(timer);
577
+ }
578
+ }
579
+ function subscribeSessionActivity(ctx, onEvent, onCredentialSwitch, onToolEvent, onStreamClosed) {
580
+ try {
581
+ const controller = new AbortController();
582
+ const stream = ctx.event.subscribe({ signal: controller.signal });
483
583
  if (!stream || typeof stream[Symbol.asyncIterator] !== "function")
484
584
  return undefined;
485
585
  let stopped = false;
486
586
  void (async () => {
487
- for await (const event of stream) {
488
- if (stopped)
489
- break;
490
- applySessionActivity(event, onCredentialSwitch);
491
- onEvent?.();
587
+ try {
588
+ for await (const event of stream) {
589
+ if (stopped)
590
+ break;
591
+ applySessionActivity(event, onCredentialSwitch);
592
+ onToolEvent?.(event);
593
+ onEvent?.();
594
+ }
595
+ }
596
+ finally {
597
+ onStreamClosed?.();
492
598
  }
493
599
  })().catch(() => { });
494
600
  return () => {
495
601
  stopped = true;
602
+ controller.abort();
496
603
  };
497
604
  }
498
605
  catch {
@@ -502,6 +609,8 @@ function subscribeSessionActivity(ctx, onEvent, onCredentialSwitch) {
502
609
  function applySessionActivity(event, onCredentialSwitch) {
503
610
  const payload = eventPayload(event);
504
611
  const info = payload?.info;
612
+ applyHostPromptEvent(sessionActivity, event?.type, payload);
613
+ applyHostToolEvent(sessionActivity, event?.type, payload);
505
614
  switch (event?.type) {
506
615
  case "credential.switched":
507
616
  case "credential.updated": {
@@ -528,8 +637,8 @@ function applySessionActivity(event, onCredentialSwitch) {
528
637
  if (id) {
529
638
  sessionActivity.removeSession(id);
530
639
  clearSessionTodos(id);
640
+ forgetEarlySteers(id);
531
641
  clearActiveCursorMode(id);
532
- cancelPlanExecutionKickoff(id);
533
642
  cancelHostAgentModeSwitch(id);
534
643
  }
535
644
  break;
@@ -553,6 +662,42 @@ function applySessionActivity(event, onCredentialSwitch) {
553
662
  sessionActivity.recordActivity(id);
554
663
  break;
555
664
  }
665
+ case "session.inbox.enqueued": {
666
+ const id = payload?.sessionID;
667
+ if (id)
668
+ sessionActivity.recordActivity(id);
669
+ const steer = inboxSteer(id, payload?.inboxID, payload?.item);
670
+ if (!steer)
671
+ break;
672
+ if (payload.item.delivery === "steer")
673
+ announceInboxSteer(steer);
674
+ else
675
+ rememberQueuedSteer(steer);
676
+ break;
677
+ }
678
+ case "session.inbox.delivery.changed": {
679
+ const id = payload?.sessionID;
680
+ if (id)
681
+ sessionActivity.recordActivity(id);
682
+ if (payload?.delivery !== "steer" || typeof payload?.inboxID !== "string")
683
+ break;
684
+ const steer = takeQueuedSteer(payload.inboxID);
685
+ if (steer)
686
+ announceInboxSteer(steer);
687
+ break;
688
+ }
689
+ case "session.inbox.delivered":
690
+ case "session.inbox.cancelled": {
691
+ const id = payload?.sessionID;
692
+ if (id)
693
+ sessionActivity.recordActivity(id);
694
+ if (typeof payload?.inboxID !== "string")
695
+ break;
696
+ takeQueuedSteer(payload.inboxID);
697
+ if (event.type === "session.inbox.cancelled" && typeof id === "string")
698
+ forgetEarlySteerOfInbox(id, payload.inboxID);
699
+ break;
700
+ }
556
701
  default: {
557
702
  // OpenCode 2.0 emits granular `session.*` progress events instead of
558
703
  // the V1 `message.updated` family (`session.tool.called/success/failed`,
@@ -563,9 +708,32 @@ function applySessionActivity(event, onCredentialSwitch) {
563
708
  const id = payload?.sessionID ?? info?.sessionID ?? info?.id;
564
709
  if (id)
565
710
  sessionActivity.recordActivity(id);
711
+ if (id && type === "session.execution.interrupted") {
712
+ void notifyHostInterrupt(id, typeof payload?.reason === "string" ? payload.reason : "user", typeof event.created === "number" ? event.created : Date.now());
713
+ }
566
714
  }
567
715
  break;
568
716
  }
569
717
  }
570
718
  }
719
+ /** A plain-text user message; one with files or skills reaches the prompt in another shape. */
720
+ function inboxSteer(sessionID, inboxID, item) {
721
+ if (typeof sessionID !== "string" || !sessionID || typeof inboxID !== "string" || !inboxID)
722
+ return undefined;
723
+ if (item?.type !== "user" || (item.delivery !== "steer" && item.delivery !== "queue"))
724
+ return undefined;
725
+ const prompt = item.payload;
726
+ if (typeof prompt?.text !== "string" || !prompt.text.trim())
727
+ return undefined;
728
+ if (prompt.files?.length || prompt.skills?.length)
729
+ return undefined;
730
+ return { sessionID, inboxID, text: prompt.text };
731
+ }
732
+ function announceInboxSteer(steer) {
733
+ const taken = announceHostSteer(steer);
734
+ if (taken === undefined)
735
+ return;
736
+ trace(`steer: OpenCode enqueued a mid-step message sessionID=${steer.sessionID} inboxID=${steer.inboxID} ` +
737
+ `chars=${steer.text.length} ${taken ? "injected into the pumping Run" : "left for the next step"}`);
738
+ }
571
739
  export default plugin;