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
package/SECURITY.md CHANGED
@@ -1,31 +1,52 @@
1
- # Security notes
1
+ # Security
2
2
 
3
- This provider mirrors OpenCode’s trust model for project configuration. That has implications for what ends up in Cursor context.
3
+ ## Threat Model
4
4
 
5
- ## Project `instructions` can include arbitrary local paths
5
+ ### Overview
6
6
 
7
- OpenCode’s `opencode.json` / `opencode.jsonc` `instructions` array may list:
7
+ cursor-opencode-provider is an OpenCode plugin and AI SDK provider that speaks Cursor's private agent protocol. Through the host it can trigger powerful tools (shell, file operations, network access via host MCP/tools) and it sends workspace context, instructions, and tool results to Cursor.
8
+
9
+ ### No Sandbox
10
+
11
+ Neither this provider nor OpenCode's permission system is a security sandbox. Permissions help users review and control actions; they are not an isolation boundary.
12
+
13
+ If you need isolation, run the host in a disposable container, virtual machine, or similarly restricted environment. Review project instructions and third-party tool or MCP server configuration before allowing them to execute.
14
+
15
+ ### Project `instructions` can include arbitrary local paths
16
+
17
+ This provider mirrors OpenCode's trust model for project configuration. That has implications for what ends up in Cursor context.
18
+
19
+ OpenCode's `opencode.json` / `opencode.jsonc` `instructions` array may list:
8
20
 
9
21
  - relative paths / globs (e.g. `.cursor/rules/*.md`)
10
22
  - absolute paths
11
23
  - home-relative paths (`~/…`)
12
- - remote URLs (`https://…`; this provider fetches HTTPS only)
24
+ - remote URLs supported by the host
13
25
 
14
- OpenCode itself expands `~/`, accepts absolute paths, and injects those file contents into the model prompt for **every** provider. This package does the same discovery when building Cursor `RequestContext.rules`.
26
+ OpenCode owns instruction discovery and supplies the resulting system prompt
27
+ to the AI SDK provider. This package forwards that prompt as Cursor's
28
+ always-apply system-instructions rule; it does not independently read instruction
29
+ or skill file bodies or fetch instruction URLs. Its merged-config read supplies
30
+ MCP server ids, plugin metadata and interaction guidance
31
+ ([`src/context/rules.ts`](src/context/rules.ts),
32
+ [`src/context/build.ts`](src/context/build.ts)).
15
33
 
16
34
  So a project config like:
17
35
 
18
36
  ```json
19
37
  {
20
- "instructions": ["~/.ssh/id_rsa", "/etc/passwd"]
38
+ "instructions": ["docs/project-guide.md", "/absolute/path/to/approved-guide.md"]
21
39
  }
22
40
  ```
23
41
 
24
- can cause those files to be read and sent to the model provider (OpenCode prompt path and/or this provider’s Cursor `RequestContext`). That is intentional OpenCode parity, not a Cursor-only hole. There is no path allowlist that rejects absolute/`~/` instruction paths (matching OpenCode; OpenCode has not treated this as a defect).
42
+ can cause those files to be read by OpenCode and included in the prompt sent to
43
+ Cursor. The provider does not add an instruction-path allowlist or remove content
44
+ already supplied by the host. Review instruction paths before enabling project
45
+ configuration, especially if they could refer to sensitive local files.
25
46
 
26
47
  Treat project `opencode.json` as **trusted**. Do not open untrusted repositories with project config enabled if that is unacceptable.
27
48
 
28
- ## Mitigation: disable project config
49
+ #### Mitigation: disable project config
29
50
 
30
51
  To ignore project-level OpenCode config (including project `instructions` and project `AGENTS.md` / `CLAUDE.md` / `CONTEXT.md` discovery), set:
31
52
 
@@ -33,10 +54,37 @@ To ignore project-level OpenCode config (including project `instructions` and pr
33
54
  export OPENCODE_DISABLE_PROJECT_CONFIG=1
34
55
  ```
35
56
 
36
- OpenCode honors this flag when loading config and assembling prompts. This provider honors the same flag when collecting rules for Cursor `RequestContext`, so project `instructions` are not merged and project instruction files are not auto-discovered. Global config under `~/.config/opencode` (and `~/.claude/CLAUDE.md` when applicable) still applies.
57
+ Set this before starting OpenCode and use the host's project-config controls to
58
+ suppress project instructions. The provider also skips its own project config
59
+ metadata reads when this flag is set; global config still applies. It cannot
60
+ remove instruction text that a host has already included in the supplied prompt
61
+ ([`loadMergedConfig`](src/context/rules.ts)).
37
62
 
38
63
  Truthy values: `1` or `true` (case-insensitive), same as OpenCode.
39
64
 
40
- ## Related hardening in this provider
65
+ Instruction URL fetching and redirect policy belong to the host; this provider
66
+ has no separate instruction-fetch policy.
67
+
68
+ ### Out of Scope
69
+
70
+ | Category | Rationale |
71
+ | --- | --- |
72
+ | **Sandbox escapes** | The host permission system is not a sandbox (see above) |
73
+ | **Cursor / LLM data handling** | Data sent to Cursor is governed by Cursor's policies and your account terms |
74
+ | **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
75
+ | **Malicious project config** | Users control their own configuration; trusted project `instructions` reading absolute/`~/` paths is expected OpenCode parity (see above) |
76
+ | **Host tool execution after user approval** | Actions the host runs under its normal permission prompts are expected behavior |
77
+
78
+ ---
79
+
80
+ ## Reporting Security Issues
81
+
82
+ Do not report a suspected vulnerability in a public GitHub issue, discussion, or pull request.
83
+
84
+ Use GitHub's private [vulnerability reporting form](https://github.com/oakimov/cursor-opencode-provider/security/advisories/new). This keeps the report and subsequent discussion private between the reporter and the repository maintainers.
85
+
86
+ Include the affected version, environment (OpenCode 1.x vs 2.0, plugin entrypoint), impact, reproduction steps, and any suggested mitigation. The initial message should not include credentials, personal data, or exploit code beyond what is needed to reproduce the issue. If the report requires sensitive supporting material, first send a minimal description and ask the maintainers to coordinate a suitable transfer method.
87
+
88
+ Reports must describe a concrete, reproducible security impact. Automated scanner output or model-generated speculation without validation may not receive a response.
41
89
 
42
- - Remote `instructions` URLs are **HTTPS-only** (`http://` is skipped). Redirects (including to a local proxy) are intentional and not blocked.
90
+ We will make a reasonable effort to acknowledge valid reports, but we cannot promise a specific response or remediation timeline.
@@ -1,15 +1,87 @@
1
+ /** A host tool may keep its Run open this long from its own start. */
2
+ export declare const RUNNING_TOOL_CAP_MS: number;
3
+ type RunningTool = {
4
+ sessionId: string;
5
+ startedAt: number;
6
+ };
7
+ /**
8
+ * Everything the tracker knows, as plain data. The plugin that receives host
9
+ * events and the provider that holds the Run can be different module copies
10
+ * (OpenCode 2 loads one per location), so the process-wide tracker keeps this
11
+ * in `processShared`: parent links, activity and prompts must all be seen by
12
+ * the copy that renews the Run's lease.
13
+ */
14
+ export type SessionActivityState = {
15
+ parentBySession: Map<string, string>;
16
+ lastActivityBySession: Map<string, number>;
17
+ /** Open host prompts: `${sessionId}\0${requestId}` → session id and when it opened. */
18
+ openPrompts: Map<string, {
19
+ sessionId: string;
20
+ openedAt: number;
21
+ }>;
22
+ /** `${sessionId}\0${callId}` → when that host tool started running. */
23
+ runningTools: Map<string, RunningTool>;
24
+ };
25
+ export declare function createSessionActivityState(): SessionActivityState;
1
26
  export type SessionActivitySource = {
2
27
  lastActivityAt(sessionId: string): number | undefined;
28
+ /**
29
+ * Latest 4h ceiling among host tools still running in this session or a
30
+ * descendant. Undefined when none are still inside their own ceiling.
31
+ */
32
+ runningToolDeadline?(sessionId: string, now: number, toolCapMs: number): number | undefined;
3
33
  };
4
- /** Tracks OpenCode message progress and propagates it through subagent ancestry. */
34
+ /**
35
+ * Tracks OpenCode message progress and propagates it through subagent ancestry.
36
+ *
37
+ * A permission or question prompt the host has open is waiting on the user, not
38
+ * stalled: the host emits no events until the user answers, so an open prompt
39
+ * counts as current activity for its session and every ancestor.
40
+ */
5
41
  export declare class SessionActivityTracker implements SessionActivitySource {
6
42
  private readonly parentBySession;
7
43
  private readonly lastActivityBySession;
44
+ private readonly openPrompts;
45
+ private readonly runningTools;
46
+ constructor(state?: SessionActivityState);
8
47
  linkSession(sessionId: string, parentId?: string): void;
9
48
  recordActivity(sessionId: string, at?: number): void;
10
49
  lastActivityAt(sessionId: string): number | undefined;
50
+ /** The host opened a permission or question prompt in `sessionId`. */
51
+ openPrompt(sessionId: string, requestId: string, at?: number): void;
52
+ /** The prompt was answered, rejected or cancelled. */
53
+ closePrompt(sessionId: string, requestId: string, at?: number): void;
54
+ /**
55
+ * The host started running this tool. A repeated start keeps the original
56
+ * time so a later event cannot extend the 4h ceiling.
57
+ */
58
+ toolStarted(sessionId: string, callId: string, at?: number): void;
59
+ /** Drop every running tool for this session. Activity history stays. */
60
+ endRunningTools(sessionId: string, at?: number): void;
61
+ /** The host finished the tool. The normal inactivity cap applies from now. */
62
+ toolEnded(sessionId: string, callId: string, at?: number): void;
63
+ runningToolDeadline(sessionId: string, now: number, toolCapMs: number): number | undefined;
11
64
  removeSession(sessionId: string): void;
12
65
  clear(): void;
66
+ /** A prompt is open in `sessionId` or in one of its descendants. */
67
+ private awaitsUser;
13
68
  private prune;
69
+ private belongsTo;
70
+ private clearRunningTools;
14
71
  }
15
72
  export declare const sessionActivity: SessionActivityTracker;
73
+ /**
74
+ * Open or close a host prompt from an OpenCode event. OpenCode 1.x publishes
75
+ * `permission.asked` / `permission.replied` and `question.asked` /
76
+ * `question.replied` / `question.rejected` (older releases used
77
+ * `permission.updated` and `permissionID`); OpenCode 2 publishes the same
78
+ * permission events plus `form.created` / `form.replied` / `form.cancelled`.
79
+ */
80
+ export declare function applyHostPromptEvent(tracker: SessionActivityTracker, type: unknown, payload: unknown): void;
81
+ /**
82
+ * A host tool that is actually running holds the Run past the inactivity cap.
83
+ * OpenCode 1 reports that on the tool part (`running`, and not provider-executed).
84
+ * OpenCode 2 reports it on `session.tool.called` when `executed` is not true.
85
+ */
86
+ export declare function applyHostToolEvent(tracker: SessionActivityTracker, type: unknown, payload: unknown): void;
87
+ export {};
package/dist/activity.js CHANGED
@@ -1,10 +1,35 @@
1
+ import { processShared } from "./process-shared.js";
2
+ /** A host tool may keep its Run open this long from its own start. */
3
+ export const RUNNING_TOOL_CAP_MS = 4 * 60 * 60 * 1_000;
4
+ export function createSessionActivityState() {
5
+ return {
6
+ parentBySession: new Map(),
7
+ lastActivityBySession: new Map(),
8
+ openPrompts: new Map(),
9
+ runningTools: new Map(),
10
+ };
11
+ }
1
12
  const MAX_ANCESTRY_DEPTH = 64;
2
13
  const MAX_TRACKED_SESSIONS = 1_024;
3
14
  const ACTIVITY_RETENTION_MS = 24 * 60 * 60 * 1_000;
4
- /** Tracks OpenCode message progress and propagates it through subagent ancestry. */
15
+ /**
16
+ * Tracks OpenCode message progress and propagates it through subagent ancestry.
17
+ *
18
+ * A permission or question prompt the host has open is waiting on the user, not
19
+ * stalled: the host emits no events until the user answers, so an open prompt
20
+ * counts as current activity for its session and every ancestor.
21
+ */
5
22
  export class SessionActivityTracker {
6
- parentBySession = new Map();
7
- lastActivityBySession = new Map();
23
+ parentBySession;
24
+ lastActivityBySession;
25
+ openPrompts;
26
+ runningTools;
27
+ constructor(state = createSessionActivityState()) {
28
+ this.parentBySession = state.parentBySession;
29
+ this.lastActivityBySession = state.lastActivityBySession;
30
+ this.openPrompts = state.openPrompts;
31
+ this.runningTools = state.runningTools;
32
+ }
8
33
  linkSession(sessionId, parentId) {
9
34
  if (!sessionId)
10
35
  return;
@@ -39,19 +64,114 @@ export class SessionActivityTracker {
39
64
  this.prune(at);
40
65
  }
41
66
  lastActivityAt(sessionId) {
42
- this.prune(Date.now());
67
+ const now = Date.now();
68
+ this.prune(now);
69
+ if (this.awaitsUser(sessionId))
70
+ return now;
43
71
  return this.lastActivityBySession.get(sessionId);
44
72
  }
73
+ /** The host opened a permission or question prompt in `sessionId`. */
74
+ openPrompt(sessionId, requestId, at = Date.now()) {
75
+ if (!sessionId || !requestId)
76
+ return;
77
+ const key = `${sessionId}\0${requestId}`;
78
+ this.openPrompts.delete(key);
79
+ this.openPrompts.set(key, { sessionId, openedAt: at });
80
+ this.recordActivity(sessionId, at);
81
+ }
82
+ /** The prompt was answered, rejected or cancelled. */
83
+ closePrompt(sessionId, requestId, at = Date.now()) {
84
+ if (!sessionId || !requestId)
85
+ return;
86
+ this.openPrompts.delete(`${sessionId}\0${requestId}`);
87
+ this.recordActivity(sessionId, at);
88
+ }
89
+ /**
90
+ * The host started running this tool. A repeated start keeps the original
91
+ * time so a later event cannot extend the 4h ceiling.
92
+ */
93
+ toolStarted(sessionId, callId, at = Date.now()) {
94
+ if (!sessionId || !callId || !Number.isFinite(at))
95
+ return;
96
+ const key = `${sessionId}\0${callId}`;
97
+ if (!this.runningTools.has(key))
98
+ this.runningTools.set(key, { sessionId, startedAt: at });
99
+ this.recordActivity(sessionId, at);
100
+ }
101
+ /** Drop every running tool for this session. Activity history stays. */
102
+ endRunningTools(sessionId, at = Date.now()) {
103
+ if (!sessionId)
104
+ return;
105
+ let ended = false;
106
+ for (const [key, tool] of this.runningTools) {
107
+ if (tool.sessionId !== sessionId)
108
+ continue;
109
+ this.runningTools.delete(key);
110
+ ended = true;
111
+ }
112
+ if (ended)
113
+ this.recordActivity(sessionId, at);
114
+ }
115
+ /** The host finished the tool. The normal inactivity cap applies from now. */
116
+ toolEnded(sessionId, callId, at = Date.now()) {
117
+ if (!sessionId || !callId)
118
+ return;
119
+ this.runningTools.delete(`${sessionId}\0${callId}`);
120
+ this.recordActivity(sessionId, at);
121
+ }
122
+ runningToolDeadline(sessionId, now, toolCapMs) {
123
+ let deadline;
124
+ for (const tool of this.runningTools.values()) {
125
+ if (!this.belongsTo(tool.sessionId, sessionId))
126
+ continue;
127
+ const cap = tool.startedAt + toolCapMs;
128
+ if (now >= cap)
129
+ continue;
130
+ deadline = deadline === undefined ? cap : Math.max(deadline, cap);
131
+ }
132
+ return deadline;
133
+ }
45
134
  removeSession(sessionId) {
46
135
  this.parentBySession.delete(sessionId);
47
136
  this.lastActivityBySession.delete(sessionId);
137
+ for (const [key, prompt] of this.openPrompts) {
138
+ if (prompt.sessionId === sessionId)
139
+ this.openPrompts.delete(key);
140
+ }
141
+ this.clearRunningTools(sessionId);
48
142
  }
49
143
  clear() {
50
144
  this.parentBySession.clear();
51
145
  this.lastActivityBySession.clear();
146
+ this.openPrompts.clear();
147
+ this.runningTools.clear();
148
+ }
149
+ /** A prompt is open in `sessionId` or in one of its descendants. */
150
+ awaitsUser(sessionId) {
151
+ for (const prompt of this.openPrompts.values()) {
152
+ let current = prompt.sessionId;
153
+ for (let depth = 0; current && depth < MAX_ANCESTRY_DEPTH; depth++) {
154
+ if (current === sessionId)
155
+ return true;
156
+ current = this.parentBySession.get(current);
157
+ }
158
+ }
159
+ return false;
52
160
  }
53
161
  prune(now) {
54
162
  const oldestAllowed = now - ACTIVITY_RETENTION_MS;
163
+ // A prompt whose reply never arrived must not hold a Run open forever.
164
+ for (const [key, prompt] of this.openPrompts) {
165
+ if (prompt.openedAt >= oldestAllowed)
166
+ break;
167
+ this.openPrompts.delete(key);
168
+ }
169
+ while (this.openPrompts.size > MAX_TRACKED_SESSIONS) {
170
+ const oldest = this.openPrompts.keys().next().value;
171
+ if (!oldest)
172
+ break;
173
+ this.openPrompts.delete(oldest);
174
+ }
55
175
  for (const [sessionId, activityAt] of this.lastActivityBySession) {
56
176
  if (activityAt >= oldestAllowed)
57
177
  break;
@@ -71,6 +191,112 @@ export class SessionActivityTracker {
71
191
  break;
72
192
  this.parentBySession.delete(oldest);
73
193
  }
194
+ for (const [key, tool] of this.runningTools) {
195
+ if (tool.startedAt < oldestAllowed)
196
+ this.runningTools.delete(key);
197
+ }
198
+ // A tool whose end event never arrived must not grow the map without bound.
199
+ while (this.runningTools.size > MAX_TRACKED_SESSIONS) {
200
+ const oldest = this.runningTools.keys().next().value;
201
+ if (!oldest)
202
+ break;
203
+ this.runningTools.delete(oldest);
204
+ }
205
+ }
206
+ belongsTo(toolSessionId, sessionId) {
207
+ let current = toolSessionId;
208
+ for (let depth = 0; current && depth < MAX_ANCESTRY_DEPTH; depth++) {
209
+ if (current === sessionId)
210
+ return true;
211
+ current = this.parentBySession.get(current);
212
+ }
213
+ return false;
214
+ }
215
+ clearRunningTools(sessionId) {
216
+ for (const [key, tool] of this.runningTools) {
217
+ if (tool.sessionId === sessionId)
218
+ this.runningTools.delete(key);
219
+ }
220
+ }
221
+ }
222
+ export const sessionActivity = new SessionActivityTracker(processShared("session-activity.v1", createSessionActivityState));
223
+ /**
224
+ * Open or close a host prompt from an OpenCode event. OpenCode 1.x publishes
225
+ * `permission.asked` / `permission.replied` and `question.asked` /
226
+ * `question.replied` / `question.rejected` (older releases used
227
+ * `permission.updated` and `permissionID`); OpenCode 2 publishes the same
228
+ * permission events plus `form.created` / `form.replied` / `form.cancelled`.
229
+ */
230
+ export function applyHostPromptEvent(tracker, type, payload) {
231
+ const data = payload && typeof payload === "object" ? payload : {};
232
+ const text = (value) => typeof value === "string" ? value : "";
233
+ switch (type) {
234
+ case "permission.asked":
235
+ case "permission.updated":
236
+ case "question.asked":
237
+ tracker.openPrompt(text(data.sessionID), text(data.id));
238
+ break;
239
+ case "permission.replied":
240
+ case "question.replied":
241
+ case "question.rejected":
242
+ tracker.closePrompt(text(data.sessionID), text(data.requestID) || text(data.permissionID));
243
+ break;
244
+ case "form.created": {
245
+ const form = data.form && typeof data.form === "object" ? data.form : {};
246
+ tracker.openPrompt(text(form.sessionID), text(form.id));
247
+ break;
248
+ }
249
+ case "form.replied":
250
+ case "form.cancelled":
251
+ tracker.closePrompt(text(data.sessionID), text(data.id));
252
+ break;
253
+ }
254
+ }
255
+ /**
256
+ * A host tool that is actually running holds the Run past the inactivity cap.
257
+ * OpenCode 1 reports that on the tool part (`running`, and not provider-executed).
258
+ * OpenCode 2 reports it on `session.tool.called` when `executed` is not true.
259
+ */
260
+ export function applyHostToolEvent(tracker, type, payload) {
261
+ const data = payload && typeof payload === "object" ? payload : {};
262
+ const text = (value) => typeof value === "string" ? value : "";
263
+ if (type === "message.part.updated") {
264
+ const part = data.part && typeof data.part === "object" ? data.part : undefined;
265
+ if (!part || part.type !== "tool")
266
+ return;
267
+ const sessionId = text(part.sessionID);
268
+ const callId = text(part.callID) || text(part.callId);
269
+ const state = part.state && typeof part.state === "object" ? part.state : undefined;
270
+ const metadata = part.metadata && typeof part.metadata === "object"
271
+ ? part.metadata
272
+ : undefined;
273
+ if (!sessionId || !callId || !state)
274
+ return;
275
+ if (state.status === "running" && metadata?.providerExecuted !== true)
276
+ tracker.toolStarted(sessionId, callId);
277
+ else if (state.status === "completed" || state.status === "error")
278
+ tracker.toolEnded(sessionId, callId);
279
+ return;
280
+ }
281
+ if (type === "session.tool.called") {
282
+ const sessionId = text(data.sessionID);
283
+ const callId = text(data.id) || text(data.callID);
284
+ if (!sessionId || !callId || data.executed === true)
285
+ return;
286
+ tracker.toolStarted(sessionId, callId);
287
+ return;
288
+ }
289
+ if (type === "session.tool.success" || type === "session.tool.failed") {
290
+ const sessionId = text(data.sessionID);
291
+ const callId = text(data.id) || text(data.callID);
292
+ if (sessionId && callId)
293
+ tracker.toolEnded(sessionId, callId);
294
+ return;
295
+ }
296
+ if (type === "session.idle") {
297
+ const info = data.info && typeof data.info === "object" ? data.info : undefined;
298
+ const sessionId = text(data.sessionID) || text(info?.id);
299
+ if (sessionId)
300
+ tracker.endRunningTools(sessionId);
74
301
  }
75
302
  }
76
- export const sessionActivity = new SessionActivityTracker();
package/dist/agent-url.js CHANGED
@@ -2,10 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import { fetchAgentUrl } from "./transport/connect.js";
3
3
  import { errorMessage, trace } from "./debug.js";
4
4
  import { CURSOR_API_HOST } from "./shared.js";
5
+ import { processShared } from "./process-shared.js";
6
+ import { CursorAuthError, CursorProtocolError, CursorProviderError, CursorServerError, CursorTransportError, toCursorProviderError, } from "./errors.js";
5
7
  const DEFAULT_API_BASE = `https://${CURSOR_API_HOST}`;
6
8
  // In-process memo of the region-specific Run stream origin (agentnUrl). Resolved
7
- // once per process — the auth loader warms it, and the first startSession reuses
8
- // it — and held for the process lifetime. Region routing is near-static, and
9
+ // once per process, shared by every copy of this package that the host loads.
10
+ // The auth loader warms it and the first startSession reuses it. It is held
11
+ // for the process lifetime. Region routing is near-static, and
9
12
  // re-resolving mid-session would break a held-open bidi Run stream anyway.
10
13
  //
11
14
  // Unlike the models/version caches this is NOT persisted to disk. A wrong agent
@@ -26,9 +29,23 @@ function resolveCacheKey(token, options) {
26
29
  const tokenHash = createHash("sha256").update(token).digest("hex").slice(0, 16);
27
30
  return `${tokenHash}|${normalizeApiBaseURL(options.apiBaseURL ?? options.baseURL)}|telem:${options.telemetryEnabled === true}`;
28
31
  }
29
- const _resolved = new Map();
30
- // In-flight fetches share the same key as resolved URLs so concurrent callers dedup per account.
31
- const _inflight = new Map();
32
+ const _resolved = processShared("agent-url.resolved.v1", () => new Map());
33
+ const _inflight = processShared("agent-url.inflight.v2", () => new Map());
34
+ // A shared promise must not reject with another module graph's error instance,
35
+ // nor let one caller's replay-safety mutation alter a sibling caller's error.
36
+ async function localResult(promise) {
37
+ const result = await promise;
38
+ if ("url" in result)
39
+ return result.url;
40
+ const { message, ...options } = result.failure;
41
+ switch (options.origin) {
42
+ case "auth": throw new CursorAuthError(message, options);
43
+ case "transport": throw new CursorTransportError(message, options);
44
+ case "server": throw new CursorServerError(message, options);
45
+ case "protocol": throw new CursorProtocolError(message, options);
46
+ default: throw new CursorProviderError(message, options);
47
+ }
48
+ }
32
49
  /**
33
50
  * Resolve the Run stream origin for this account via the `GetServerConfig`
34
51
  * Connect RPC. Memoized for the process lifetime.
@@ -49,26 +66,37 @@ export async function resolveAgentUrl(token, options = {}) {
49
66
  const inflight = _inflight.get(cacheKey);
50
67
  if (inflight) {
51
68
  trace("agent-url: awaiting in-flight GetServerConfig");
52
- return inflight;
69
+ return localResult(inflight);
53
70
  }
54
71
  const promise = (async () => {
55
72
  try {
56
73
  const url = await fetchAgentUrl(token, options);
57
74
  _resolved.set(cacheKey, url);
58
75
  trace(`agent-url: resolved via GetServerConfig → ${url}`);
59
- return url;
76
+ return { url };
60
77
  }
61
78
  catch (err) {
62
79
  const reason = errorMessage(err);
63
80
  trace(`agent-url: GetServerConfig failed (${reason}); no fallback agent host will be used`);
64
- throw err;
81
+ const failure = toCursorProviderError(err, { replaySafe: true });
82
+ return { failure: {
83
+ message: failure.message,
84
+ origin: failure.origin,
85
+ transient: failure.transient,
86
+ replaySafe: failure.replaySafe,
87
+ statusCode: failure.statusCode,
88
+ grpcStatus: failure.grpcStatus,
89
+ rstCode: failure.rstCode,
90
+ code: failure.code,
91
+ retryAfterMs: failure.retryAfterMs,
92
+ } };
65
93
  }
66
94
  finally {
67
95
  _inflight.delete(cacheKey);
68
96
  }
69
97
  })();
70
98
  _inflight.set(cacheKey, promise);
71
- return promise;
99
+ return localResult(promise);
72
100
  }
73
101
  /** Reset the in-process memo. Tests only. */
74
102
  export function resetAgentUrlCache() {
@@ -33,7 +33,7 @@ export type SessionRenewal = {
33
33
  * older stored credential (persisting failed, or a host that persists only
34
34
  * later) gets the newest token instead of renewing again.
35
35
  */
36
- export declare function latestSessionTokens(tokens: SessionTokens): SessionTokens;
36
+ export declare function latestSessionTokens(tokens: SessionTokens, baseUrl?: string): SessionTokens;
37
37
  export declare function isSessionRenewalDue(token: string, now?: number): boolean;
38
38
  /**
39
39
  * Return a usable session token, renewing it first when it is due (or when