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
@@ -7,7 +7,8 @@ import { collectProjectLayout } from "./layout.js";
7
7
  import { buildEnv } from "./env.js";
8
8
  import { ensureOpencodeProjectDir } from "./paths.js";
9
9
  import { holdCapabilityOverlay } from "./overlay.js";
10
- import { traceRequestContextPaths } from "../debug.js";
10
+ import { agentSkillsForCursor, applyAgentSkillsToContext, hostSkillFiles, loadBridgeSkills, skillToolAdvertised, } from "./skills.js";
11
+ import { trace, traceRequestContextPaths } from "../debug.js";
11
12
  /** Rule name Cursor shows for the host system context (not a file). */
12
13
  export const SYSTEM_INSTRUCTIONS_RULE_PATH = "OpenCode system instructions";
13
14
  /**
@@ -74,8 +75,8 @@ export const DYNAMIC_REQUEST_CONTEXT_KEYS = [
74
75
  "hooks_additional_context",
75
76
  ];
76
77
  /**
77
- * The host system context (delivered as the system-instructions rule) already
78
- * carries these; never keep them on a frozen base.
78
+ * Derived from the system-instructions rule on every materialization (see
79
+ * `skills.ts`); never kept on a frozen base.
79
80
  */
80
81
  export const HOST_DUPLICATED_REQUEST_CONTEXT_KEYS = [
81
82
  "agent_skills",
@@ -94,6 +95,34 @@ function stripHostDuplicatedRequestContextFields(context) {
94
95
  delete context[key];
95
96
  keepSystemInstructionsRule(context);
96
97
  }
98
+ /**
99
+ * How long one workspace's git and layout discovery serves later builds. A new
100
+ * session's first message opens two Runs moments apart (OpenCode's title
101
+ * request and the turn itself) on separate conversations; each froze its own
102
+ * copy, and `git status` dominates on a large repository (1.3 s warm on 35k
103
+ * tracked files; both discoveries of one observed session took about 5 s).
104
+ * Each conversation freezes what it got anyway.
105
+ */
106
+ const WORKSPACE_FACTS_REUSE_MS = 30_000;
107
+ const workspaceFacts = new Map();
108
+ function collectWorkspaceFacts(workspaceRoot) {
109
+ const now = Date.now();
110
+ for (const [root, entry] of workspaceFacts) {
111
+ if (now - entry.at >= WORKSPACE_FACTS_REUSE_MS)
112
+ workspaceFacts.delete(root);
113
+ }
114
+ const cached = workspaceFacts.get(workspaceRoot);
115
+ if (cached) {
116
+ trace(`workspace facts: reused ageMs=${now - cached.at}`);
117
+ return cached.facts;
118
+ }
119
+ const facts = Promise.all([collectGit(workspaceRoot), collectProjectLayout(workspaceRoot)]);
120
+ workspaceFacts.set(workspaceRoot, { at: now, facts });
121
+ return facts;
122
+ }
123
+ export function resetWorkspaceFactsForTests() {
124
+ workspaceFacts.clear();
125
+ }
97
126
  /**
98
127
  * Full RequestContext payload for live UMA + exec #10 reply.
99
128
  * Workspace env/git/layout, the host system-instructions rule, and
@@ -103,10 +132,9 @@ function stripHostDuplicatedRequestContextFields(context) {
103
132
  export async function buildRequestContext(input) {
104
133
  const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
105
134
  const config = input.mergedConfig ?? await loadMergedConfig(workspaceRoot);
106
- const [dynamic, git, layout] = await Promise.all([
135
+ const [dynamic, [git, layout]] = await Promise.all([
107
136
  buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config),
108
- collectGit(workspaceRoot),
109
- collectProjectLayout(workspaceRoot),
137
+ collectWorkspaceFacts(workspaceRoot),
110
138
  ]);
111
139
  const workspace = {
112
140
  env: buildEnv(workspaceRoot),
@@ -117,13 +145,29 @@ export async function buildRequestContext(input) {
117
145
  env_info_complete: true,
118
146
  repository_info_complete: true,
119
147
  git_repo_info_complete: true,
120
- git_status_info_complete: true,
148
+ git_status_info_complete: git.statusComplete,
121
149
  };
122
150
  const base = withSystemInstructions(workspace, input.systemInstructions);
123
- const ctx = materializeRequestContext(base, dynamic);
151
+ const skillLocations = await resolveSkillLocations(input, workspaceRoot);
152
+ const ctx = materializeRequestContext(base, dynamic, {
153
+ tools: input.tools,
154
+ ...skillLocations,
155
+ });
124
156
  traceRequestContextPaths("buildRequestContext", ctx);
125
157
  return ctx;
126
158
  }
159
+ /** Locations for path-desc materialization (bridge, then remembered OC2 files). */
160
+ export async function resolveSkillLocations(input, workspaceRoot) {
161
+ if (!skillToolAdvertised(input.tools))
162
+ return {};
163
+ return {
164
+ bridgeSkills: await loadBridgeSkills({
165
+ workspaceRoot,
166
+ sessionID: input.sessionID,
167
+ }),
168
+ skillFiles: hostSkillFiles(workspaceRoot),
169
+ };
170
+ }
127
171
  async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config) {
128
172
  const providerIdentifier = input.providerIdentifier ?? "opencode";
129
173
  const tools = input.tools ?? [];
@@ -184,8 +228,13 @@ export async function buildDynamicRequestContext(input) {
184
228
  : await loadMergedConfig(workspaceRoot);
185
229
  return buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config);
186
230
  }
187
- /** Keep expensive workspace state frozen while replacing every live capability field. */
188
- export function materializeRequestContext(base, dynamic) {
231
+ /**
232
+ * Keep expensive workspace state frozen while replacing every live capability
233
+ * field. Skill locations (bridge / OpenCode 2 files / OpenCode 1 catalog
234
+ * paths) turn the skill catalog in the system-instructions rule into Cursor's
235
+ * path-desc `agent_skills`.
236
+ */
237
+ export function materializeRequestContext(base, dynamic, options) {
189
238
  const context = structuredClone(base);
190
239
  stripHostDuplicatedRequestContextFields(context);
191
240
  for (const key of DYNAMIC_REQUEST_CONTEXT_KEYS)
@@ -194,6 +243,12 @@ export function materializeRequestContext(base, dynamic) {
194
243
  if (Object.hasOwn(dynamic, key))
195
244
  context[key] = structuredClone(dynamic[key]);
196
245
  }
246
+ const skills = agentSkillsForCursor(systemInstructionsRuleText(context), {
247
+ skillToolAdvertised: skillToolAdvertised(options?.tools),
248
+ skillFiles: options?.skillFiles,
249
+ bridgeSkills: options?.bridgeSkills,
250
+ });
251
+ applyAgentSkillsToContext(context, skills);
197
252
  return context;
198
253
  }
199
254
  /** Strip live capability fields before retaining/persisting a conversation base. */
@@ -64,7 +64,12 @@ export function buildDynamicCatalogRoutingInstruction(options) {
64
64
  `- OpenCode host tools that are not in Cursor's native top-level list (including ${extras.join(" and ")}) ` +
65
65
  "are reached through GetDynamicTools / CallDynamicTool (or the host's equivalent dynamic catalog). " +
66
66
  "When a skill matches or project rules name an MCP server, discover and call those tools that way " +
67
- "before Grep/Shell fallbacks. Do not narrate that they are unavailable.",
67
+ "before Grep/Shell fallbacks. Do not narrate that they are unavailable. " +
68
+ // Cursor's server answers lookups and calls in its own `cursor` namespace
69
+ // (built-in tools such as GenerateImage) and rejects any other name there.
70
+ `Host tools are in namespace \`${DEFAULT_TOOL_SERVER}\`` +
71
+ (mcpServers.length > 0 ? " and MCP tools in their server's namespace" : "") +
72
+ "; namespace `cursor` holds only Cursor's built-in tools.",
68
73
  ];
69
74
  if (hasSkill) {
70
75
  // Mirror OpenCode 1 SystemPrompt.skills / OC2 SkillInstructions.render.
@@ -1,7 +1,21 @@
1
+ import { statSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import path from "node:path";
3
4
  import { trace } from "../debug.js";
4
5
  import { ensureOpencodeProjectDir } from "./paths.js";
6
+ import { maintainRuntimeFiles, runtimeLayout } from "../runtime-files.js";
7
+ function agentTranscriptsFolder(workspaceRoot) {
8
+ const configured = process.env.CURSOR_AGENT_TRANSCRIPTS_FOLDER;
9
+ if (!configured?.trim())
10
+ return undefined;
11
+ const folder = path.resolve(workspaceRoot, configured);
12
+ try {
13
+ return statSync(folder).isDirectory() ? folder : undefined;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
5
19
  export function buildEnv(workspaceRoot) {
6
20
  const cwd = path.resolve(workspaceRoot);
7
21
  let timeZone = "UTC";
@@ -20,6 +34,16 @@ export function buildEnv(workspaceRoot) {
20
34
  // Cursor's project_folder is a metadata root (agent-tools, terminals, …),
21
35
  // not the git workspace. Keep dumps under OpenCode cache.
22
36
  const projectFolder = ensureOpencodeProjectDir(cwd);
37
+ let terminalsFolder;
38
+ try {
39
+ const layout = runtimeLayout(projectFolder, true);
40
+ terminalsFolder = layout.terminals;
41
+ maintainRuntimeFiles(layout);
42
+ }
43
+ catch (error) {
44
+ trace(`runtime folders unavailable: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ const transcriptsFolder = agentTranscriptsFolder(cwd);
23
47
  // process_working_directory must match the workspace, not the host process
24
48
  // cwd. OpenCode 2.0's daemon is long-lived and often started from $HOME, so
25
49
  // process.cwd() would advertise the home folder to Cursor and the model would
@@ -32,15 +56,15 @@ export function buildEnv(workspaceRoot) {
32
56
  sandbox_supported: false,
33
57
  time_zone: timeZone,
34
58
  project_folder: projectFolder,
35
- terminals_folder: path.join(projectFolder, "terminals"),
36
- agent_transcripts_folder: path.join(projectFolder, "agent-transcripts"),
59
+ ...(terminalsFolder ? { terminals_folder: terminalsFolder } : {}),
60
+ ...(transcriptsFolder ? { agent_transcripts_folder: transcriptsFolder } : {}),
37
61
  process_working_directory: cwd,
38
62
  is_working_dir_home_dir: cwd === path.resolve(home),
39
63
  };
40
64
  trace(`buildEnv: workspace_paths=${JSON.stringify(env.workspace_paths)} ` +
41
65
  `project_folder=${env.project_folder} ` +
42
66
  `terminals_folder=${env.terminals_folder} ` +
43
- `agent_transcripts_folder=${env.agent_transcripts_folder} ` +
67
+ `agent_transcripts_folder=${env.agent_transcripts_folder ?? "(none)"} ` +
44
68
  `process_working_directory=${env.process_working_directory}`);
45
69
  return env;
46
70
  }
@@ -153,10 +153,13 @@ export function admitContextEpoch(input) {
153
153
  workspaceRoot: nextSnap.workspaceRoot || existing.snapshot.workspaceRoot,
154
154
  };
155
155
  const updates = renderSourceUpdates(existing.snapshot, observedSnap, input);
156
- const changed = existing.snapshot.hostSystemHash !== observedSnap.hostSystemHash
157
- || existing.snapshot.guidanceHash !== observedSnap.guidanceHash
158
- || existing.snapshot.hostAgent !== observedSnap.hostAgent
159
- || existing.snapshot.workspaceRoot !== observedSnap.workspaceRoot;
156
+ const changedSources = [
157
+ existing.snapshot.hostSystemHash !== observedSnap.hostSystemHash ? "hostSystem" : "",
158
+ existing.snapshot.guidanceHash !== observedSnap.guidanceHash ? "guidance" : "",
159
+ existing.snapshot.hostAgent !== observedSnap.hostAgent ? "hostAgent" : "",
160
+ existing.snapshot.workspaceRoot !== observedSnap.workspaceRoot ? "workspaceRoot" : "",
161
+ ].filter(Boolean);
162
+ const changed = changedSources.length > 0;
160
163
  if (changed) {
161
164
  existing.snapshot = observedSnap;
162
165
  }
@@ -165,7 +168,8 @@ export function admitContextEpoch(input) {
165
168
  const midConversationMessage = combineMessages([...updates, ...oneShots]);
166
169
  if (changed || oneShots.length) {
167
170
  trace(`context epoch: ${changed ? "updated" : "unchanged"} conversationId=${conversationId} ` +
168
- `checkpoint=1 updates=${updates.length} oneShots=${oneShots.length}`);
171
+ `checkpoint=1 updates=${updates.length} oneShots=${oneShots.length}` +
172
+ (changedSources.length ? ` changed=[${changedSources.join(",")}]` : ""));
169
173
  }
170
174
  return {
171
175
  action: changed ? "updated" : "unchanged",
@@ -1,4 +1,4 @@
1
- import { buildDynamicRequestContext, buildRequestContext, materializeRequestContext, requestContextBase, withSystemInstructions, } from "./build.js";
1
+ import { buildDynamicRequestContext, buildRequestContext, materializeRequestContext, requestContextBase, resolveSkillLocations, withSystemInstructions, } from "./build.js";
2
2
  import { clearContextEpoch, endContextEpoch, resetContextEpochsForTests } from "./epoch.js";
3
3
  import { clearOverlayHold, resetOverlayHoldsForTests, transferOverlayHold, } from "./overlay.js";
4
4
  import { trace } from "../debug.js";
@@ -174,7 +174,11 @@ export async function getOrBuildRequestContext(conversationId, input, opts) {
174
174
  `authoritative=${input.systemInstructions?.authoritative ?? false}`);
175
175
  }
176
176
  const dynamic = await buildDynamicRequestContext(scoped);
177
- const materialized = rememberMaterialized(conversationId, materializeRequestContext(base, dynamic));
177
+ const skillLocations = await resolveSkillLocations(scoped, scoped.workspaceRoot);
178
+ const materialized = rememberMaterialized(conversationId, materializeRequestContext(base, dynamic, {
179
+ tools: scoped.tools,
180
+ ...skillLocations,
181
+ }));
178
182
  trace(`request_context: materialized conversationId=${conversationId} ` +
179
183
  `tools=${advertisedMetaToolCount(materialized.context)} ` +
180
184
  `reused=${materialized.reused}`);
@@ -14,7 +14,10 @@ export type GitRepoInfo = {
14
14
  branch_name: string;
15
15
  remote_url?: string;
16
16
  };
17
- export declare function collectGit(workspaceRoot: string): Promise<{
17
+ export type GitFacts = {
18
18
  repositoryInfo: RepoInfo[];
19
19
  gitRepos: GitRepoInfo[];
20
- }>;
20
+ /** False when `git status` failed or timed out, so the status is not the real one. */
21
+ statusComplete: boolean;
22
+ };
23
+ export declare function collectGit(workspaceRoot: string): Promise<GitFacts>;
@@ -1,21 +1,29 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import path from "node:path";
4
+ import { errorMessage, trace } from "../debug.js";
4
5
  const execFileAsync = promisify(execFile);
6
+ const GIT_TIMEOUT_MS = 5_000;
7
+ /** Trimmed stdout, or `undefined` when git failed or timed out. */
5
8
  async function git(cwd, args) {
9
+ const started = Date.now();
6
10
  try {
7
- const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf-8", timeout: 5000 });
11
+ const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf-8", timeout: GIT_TIMEOUT_MS });
8
12
  return stdout.trim();
9
13
  }
10
- catch {
11
- return "";
14
+ catch (error) {
15
+ const killed = error.killed === true;
16
+ trace(`git: \`${args.join(" ")}\` ${killed ? `timed out after ${GIT_TIMEOUT_MS}ms` : "failed"} ` +
17
+ `elapsedMs=${Date.now() - started}${killed ? "" : `: ${errorMessage(error).split("\n")[0]}`}`);
18
+ return undefined;
12
19
  }
13
20
  }
14
21
  export async function collectGit(workspaceRoot) {
22
+ const started = Date.now();
15
23
  const root = await git(workspaceRoot, ["rev-parse", "--show-toplevel"]);
16
24
  if (!root)
17
- return { repositoryInfo: [], gitRepos: [] };
18
- const remotesRaw = await git(root, ["remote", "-v"]);
25
+ return { repositoryInfo: [], gitRepos: [], statusComplete: true };
26
+ const remotesRaw = (await git(root, ["remote", "-v"])) ?? "";
19
27
  const remote_urls = [];
20
28
  const remote_names = [];
21
29
  for (const line of remotesRaw.split("\n")) {
@@ -34,7 +42,9 @@ export async function collectGit(workspaceRoot) {
34
42
  repo_name = gh[2];
35
43
  }
36
44
  const branch = (await git(root, ["rev-parse", "--abbrev-ref", "HEAD"])) || "HEAD";
45
+ const statusStarted = Date.now();
37
46
  const status = await git(root, ["status", "--porcelain", "-b"]);
47
+ trace(`git: discovery elapsedMs=${Date.now() - started} statusMs=${Date.now() - statusStarted} statusComplete=${status !== undefined}`);
38
48
  const repositoryInfo = [
39
49
  {
40
50
  relative_workspace_path: ".",
@@ -50,10 +60,10 @@ export async function collectGit(workspaceRoot) {
50
60
  const gitRepos = [
51
61
  {
52
62
  path: root,
53
- status: status.slice(0, 4000),
63
+ status: (status ?? "").slice(0, 4000),
54
64
  branch_name: branch,
55
65
  ...(primary ? { remote_url: primary } : {}),
56
66
  },
57
67
  ];
58
- return { repositoryInfo, gitRepos };
68
+ return { repositoryInfo, gitRepos, statusComplete: status !== undefined };
59
69
  }
@@ -4,3 +4,5 @@ export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestCo
4
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
5
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, type AdmitContextEpochInput, type AdmitContextEpochResult, type ContextEpoch, type ContextSourceSnapshot, } from "./epoch.js";
6
6
  export { HOST_PATH_BRIDGE, getHostCacheDirOverride, opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir, hostGlobalDataDir, resolveHostCacheDir, setHostCacheDirOverride, } from "./paths.js";
7
+ export { HOST_SKILLS_BRIDGE, setHostSkillsBridgeForTests, type HostSkill, type OpenCodeSkillsBridge, } from "./skills-bridge.js";
8
+ export { agentSkillsForCursor, hostSkillFiles, rememberHostSkillFiles, resetHostSkillFilesForTests, skillToolAdvertised, } from "./skills.js";
@@ -4,3 +4,5 @@ export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestCo
4
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
5
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, } from "./epoch.js";
6
6
  export { HOST_PATH_BRIDGE, getHostCacheDirOverride, opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir, hostGlobalDataDir, resolveHostCacheDir, setHostCacheDirOverride, } from "./paths.js";
7
+ export { HOST_SKILLS_BRIDGE, setHostSkillsBridgeForTests, } from "./skills-bridge.js";
8
+ export { agentSkillsForCursor, hostSkillFiles, rememberHostSkillFiles, resetHostSkillFilesForTests, skillToolAdvertised, } from "./skills.js";
@@ -9,7 +9,31 @@ export type OpenCodePathBridge = {
9
9
  /** Optional host-owned cache root; absent means native OpenCode defaults. */
10
10
  globalCacheDir?: () => string;
11
11
  configFileNames?: string[];
12
+ /**
13
+ * Optional host plan file for a session (the host's own plan location, which
14
+ * its `plan_exit` review reads). Absent on an installed bridge means the host
15
+ * defines none; without a bridge OpenCode's own location applies.
16
+ */
17
+ planFile?: (input: {
18
+ worktree: string;
19
+ vcs: boolean;
20
+ created: number;
21
+ slug: string;
22
+ }) => string | undefined;
12
23
  };
24
+ /**
25
+ * The plan file of a session: OpenCode's own `Session.plan` location
26
+ * (`<worktree>/.opencode/plans` in a VCS project, else `<data>/plans`, named
27
+ * `<created>-<slug>.md`), which its plan agent and `plan_exit` use. An injected
28
+ * host path bridge owns host paths, so with one installed only its `planFile`
29
+ * defines the location.
30
+ */
31
+ export declare function hostPlanFilePath(input: {
32
+ worktree: string;
33
+ vcs: boolean;
34
+ created: number;
35
+ slug: string;
36
+ }, env?: HostPathEnv): string | undefined;
13
37
  export declare function opencodeProjectConfigDirs(workspaceRoot: string): string[];
14
38
  export declare function opencodeGlobalConfigDirs(): string[];
15
39
  export declare function opencodeConfigFileNames(): string[];
@@ -41,25 +65,28 @@ export declare function opencodeGlobalDataDir(env?: HostPathEnv): string;
41
65
  /** Host-portable durable data root; falls back to native OpenCode. */
42
66
  export declare function hostGlobalDataDir(env?: HostPathEnv): string;
43
67
  /**
44
- * Directory for host plan files — always `<hostGlobalDataDir()>/plans`, and
45
- * never inside the user's repository.
46
- *
47
- * OpenCode's own `Session.plan` branches on VCS and puts plans in the worktree
48
- * (`<worktree>/.opencode/plans`) for a git project. The provider deliberately
49
- * does *not* mirror that branch. Writing there means a throwaway plan lands in
50
- * the user's tree untracked-but-unignored, and — because OpenCode installs
51
- * `@opencode-ai/plugin` into every `.opencode` directory it discovers walking up
52
- * from the cwd — creating that directory also bootstraps a project-local
53
- * `node_modules`. The provider must add nothing to a repository it did not
54
- * already contain.
68
+ * Select the entrypoint's native OpenCode plan directory. OpenCode 2.0 sets its
69
+ * Plan directory ({@link opencode2PlanDir}); OpenCode 1.x keeps the default.
70
+ * The returned disposer removes only this selection, so an older plugin setup
71
+ * disposed after a newer one leaves the newer selection in place. `undefined`
72
+ * removes all.
73
+ */
74
+ export declare function setNativePlansDir(dir: string | undefined): () => void;
75
+ /**
76
+ * OpenCode 2.0's Plan directory: `<home>/.opencode/plan`, where its Plan agent
77
+ * may write plan files (home is `OPENCODE_TEST_HOME`, else the OS home).
78
+ */
79
+ export declare function opencode2PlanDir(env?: HostPathEnv): string;
80
+ /**
81
+ * Directory for a new plan file when the session's own plan file
82
+ * ({@link hostPlanFilePath}) is not known.
55
83
  *
56
- * The global-data location is not a degraded fallback: it is the branch OpenCode
57
- * itself uses when there is no VCS, and its plan agent allow-lists that path for
58
- * `edit` / `external_directory` alongside the in-worktree one. {@link
59
- * hostGlobalDataDir} carries an optional injected host translation; without a
60
- * bridge it is the native OpenCode data root.
84
+ * An injected host path bridge owns host paths: `<globalDataDir()>/plans`.
85
+ * Otherwise the native OpenCode location of the running entrypoint: OpenCode
86
+ * 2.0's Plan directory, or OpenCode 1.x's no-VCS `Session.plan` base
87
+ * (`<data>/plans`).
61
88
  */
62
- export declare function hostPlansDir(_workspaceRoot?: string): string;
89
+ export declare function hostPlansDir(_workspaceRoot?: string, env?: HostPathEnv): string;
63
90
  /**
64
91
  * Cursor-compatible path slug (`/workspace/a/b` → `workspace-a-b`).
65
92
  * Used for per-workspace metadata under the host cache.
@@ -70,7 +97,7 @@ export declare function slugifyWorkspacePath(workspaceRoot: string): string;
70
97
  * Lives at `<host-cache>/projects/<slug>/` under the resolved OpenCode/host cache root.
71
98
  *
72
99
  * This is what Cursor's RequestContextEnv.project_folder / MCP
73
- * workspace_project_dir point at — agent-tools, terminals, transcripts, etc.
100
+ * workspace_project_dir point at — agent-tools, terminals, assets, etc.
74
101
  * Must NOT be the git workspace, or those dumps land in the repo.
75
102
  */
76
103
  export declare function opencodeProjectDir(workspaceRoot: string): string;
@@ -27,6 +27,31 @@ function bridgeGlobalDataDir() {
27
27
  const value = pathBridge()?.globalDataDir?.();
28
28
  return typeof value === "string" && value.length > 0 ? path.resolve(value) : undefined;
29
29
  }
30
+ /**
31
+ * The plan file of a session: OpenCode's own `Session.plan` location
32
+ * (`<worktree>/.opencode/plans` in a VCS project, else `<data>/plans`, named
33
+ * `<created>-<slug>.md`), which its plan agent and `plan_exit` use. An injected
34
+ * host path bridge owns host paths, so with one installed only its `planFile`
35
+ * defines the location.
36
+ */
37
+ export function hostPlanFilePath(input, env = process.env) {
38
+ const bridge = pathBridge();
39
+ if (bridge) {
40
+ if (typeof bridge.planFile !== "function")
41
+ return undefined;
42
+ const value = bridge.planFile(input);
43
+ return typeof value === "string" && value.length > 0 ? path.resolve(value) : undefined;
44
+ }
45
+ const slug = input.slug.trim();
46
+ if (!slug || slug.includes("/") || slug.includes("\\") || slug.startsWith("."))
47
+ return undefined;
48
+ if (!Number.isSafeInteger(input.created) || input.created <= 0)
49
+ return undefined;
50
+ const base = input.vcs
51
+ ? path.join(path.resolve(input.worktree), ".opencode", "plans")
52
+ : path.join(openCodeGlobalDataDir(env), "plans");
53
+ return path.join(base, `${input.created}-${slug}.md`);
54
+ }
30
55
  function bridgeGlobalCacheDir() {
31
56
  const value = pathBridge()?.globalCacheDir?.();
32
57
  return typeof value === "string" && value.length > 0 ? path.resolve(value) : undefined;
@@ -102,27 +127,49 @@ export function opencodeGlobalDataDir(env = process.env) {
102
127
  export function hostGlobalDataDir(env = process.env) {
103
128
  return bridgeGlobalDataDir() ?? openCodeGlobalDataDir(env);
104
129
  }
130
+ /** Entrypoint plan directories, newest last (see {@link setNativePlansDir}). */
131
+ const nativePlansDirs = [];
105
132
  /**
106
- * Directory for host plan files — always `<hostGlobalDataDir()>/plans`, and
107
- * never inside the user's repository.
108
- *
109
- * OpenCode's own `Session.plan` branches on VCS and puts plans in the worktree
110
- * (`<worktree>/.opencode/plans`) for a git project. The provider deliberately
111
- * does *not* mirror that branch. Writing there means a throwaway plan lands in
112
- * the user's tree untracked-but-unignored, and — because OpenCode installs
113
- * `@opencode-ai/plugin` into every `.opencode` directory it discovers walking up
114
- * from the cwd — creating that directory also bootstraps a project-local
115
- * `node_modules`. The provider must add nothing to a repository it did not
116
- * already contain.
133
+ * Select the entrypoint's native OpenCode plan directory. OpenCode 2.0 sets its
134
+ * Plan directory ({@link opencode2PlanDir}); OpenCode 1.x keeps the default.
135
+ * The returned disposer removes only this selection, so an older plugin setup
136
+ * disposed after a newer one leaves the newer selection in place. `undefined`
137
+ * removes all.
138
+ */
139
+ export function setNativePlansDir(dir) {
140
+ if (!dir) {
141
+ nativePlansDirs.length = 0;
142
+ return () => { };
143
+ }
144
+ const entry = { dir: path.resolve(dir) };
145
+ nativePlansDirs.push(entry);
146
+ return () => {
147
+ const index = nativePlansDirs.lastIndexOf(entry);
148
+ if (index >= 0)
149
+ nativePlansDirs.splice(index, 1);
150
+ };
151
+ }
152
+ /**
153
+ * OpenCode 2.0's Plan directory: `<home>/.opencode/plan`, where its Plan agent
154
+ * may write plan files (home is `OPENCODE_TEST_HOME`, else the OS home).
155
+ */
156
+ export function opencode2PlanDir(env = process.env) {
157
+ return path.join(env.OPENCODE_TEST_HOME ?? homedir(), ".opencode", "plan");
158
+ }
159
+ /**
160
+ * Directory for a new plan file when the session's own plan file
161
+ * ({@link hostPlanFilePath}) is not known.
117
162
  *
118
- * The global-data location is not a degraded fallback: it is the branch OpenCode
119
- * itself uses when there is no VCS, and its plan agent allow-lists that path for
120
- * `edit` / `external_directory` alongside the in-worktree one. {@link
121
- * hostGlobalDataDir} carries an optional injected host translation; without a
122
- * bridge it is the native OpenCode data root.
163
+ * An injected host path bridge owns host paths: `<globalDataDir()>/plans`.
164
+ * Otherwise the native OpenCode location of the running entrypoint: OpenCode
165
+ * 2.0's Plan directory, or OpenCode 1.x's no-VCS `Session.plan` base
166
+ * (`<data>/plans`).
123
167
  */
124
- export function hostPlansDir(_workspaceRoot) {
125
- return path.join(hostGlobalDataDir(), "plans");
168
+ export function hostPlansDir(_workspaceRoot, env = process.env) {
169
+ const bridged = bridgeGlobalDataDir();
170
+ if (bridged)
171
+ return path.join(bridged, "plans");
172
+ return nativePlansDirs[nativePlansDirs.length - 1]?.dir ?? path.join(openCodeGlobalDataDir(env), "plans");
126
173
  }
127
174
  /**
128
175
  * Cursor-compatible path slug (`/workspace/a/b` → `workspace-a-b`).
@@ -141,17 +188,20 @@ export function slugifyWorkspacePath(workspaceRoot) {
141
188
  * Lives at `<host-cache>/projects/<slug>/` under the resolved OpenCode/host cache root.
142
189
  *
143
190
  * This is what Cursor's RequestContextEnv.project_folder / MCP
144
- * workspace_project_dir point at — agent-tools, terminals, transcripts, etc.
191
+ * workspace_project_dir point at — agent-tools, terminals, assets, etc.
145
192
  * Must NOT be the git workspace, or those dumps land in the repo.
146
193
  */
147
194
  export function opencodeProjectDir(workspaceRoot) {
148
195
  const projectsRoot = path.join(opencodeGlobalCacheDir(), "projects");
149
196
  const slug = slugifyWorkspacePath(workspaceRoot);
150
- let dir = path.join(projectsRoot, slug);
151
- // Mirror Cursor's long-path guard so nested agent-tools paths stay usable.
197
+ const dir = path.join(projectsRoot, slug);
198
+ // Keep nested agent-tools paths short by cutting the name to 84 characters.
199
+ // While that cut falls after `projects/`, it only shortens the slug, and it
200
+ // is the name existing folders already have. With a longer root it would
201
+ // fall inside the root, so the folder is named by the hash alone.
152
202
  if (dir.length > 92) {
153
203
  const hash = createHash("sha256").update(dir).digest("hex").slice(0, 7);
154
- dir = `${dir.slice(0, Math.min(84, dir.length))}-${hash}`;
204
+ return projectsRoot.length < 84 ? `${dir.slice(0, 84)}-${hash}` : path.join(projectsRoot, hash);
155
205
  }
156
206
  return dir;
157
207
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Structural host-skills capability installed before an unchanged provider loads.
3
+ * Same install timing as `opencode.host.path-bridge`.
4
+ */
5
+ export declare const HOST_SKILLS_BRIDGE: unique symbol;
6
+ /** One host catalog skill the provider may advertise to Cursor. */
7
+ export type HostSkill = {
8
+ name: string;
9
+ id?: string;
10
+ description: string;
11
+ /** Absolute path, `file:` URL, or host URI (`skill://…`). Omit built-ins / markers. */
12
+ location?: string;
13
+ };
14
+ export type OpenCodeSkillsBridge = {
15
+ list(input: {
16
+ directory: string;
17
+ sessionID?: string;
18
+ }): Promise<HostSkill[]>;
19
+ };
20
+ export declare function skillsBridge(): OpenCodeSkillsBridge | undefined;
21
+ /** Test helper: install or clear the structural skills bridge. */
22
+ export declare function setHostSkillsBridgeForTests(bridge: OpenCodeSkillsBridge | undefined): void;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Structural host-skills capability installed before an unchanged provider loads.
3
+ * Same install timing as `opencode.host.path-bridge`.
4
+ */
5
+ export const HOST_SKILLS_BRIDGE = Symbol.for("opencode.host.skills");
6
+ export function skillsBridge() {
7
+ const value = globalThis[HOST_SKILLS_BRIDGE];
8
+ if (!value || typeof value !== "object")
9
+ return undefined;
10
+ const bridge = value;
11
+ return typeof bridge.list === "function" ? bridge : undefined;
12
+ }
13
+ /** Test helper: install or clear the structural skills bridge. */
14
+ export function setHostSkillsBridgeForTests(bridge) {
15
+ const globals = globalThis;
16
+ if (bridge)
17
+ globals[HOST_SKILLS_BRIDGE] = bridge;
18
+ else
19
+ delete globals[HOST_SKILLS_BRIDGE];
20
+ }
@@ -0,0 +1,34 @@
1
+ import type { OpencodeToolDef } from "../protocol/tools.js";
2
+ import { type HostSkill } from "./skills-bridge.js";
3
+ export type HostSkillFileEntry = {
4
+ readonly id: string;
5
+ readonly path: string;
6
+ };
7
+ export type AgentSkillWire = {
8
+ readonly full_path: string;
9
+ readonly description: string;
10
+ };
11
+ /** Locations Cursor can advertise; markers / builtins are omitted. */
12
+ export declare function isUsableSkillLocation(location: string | undefined): boolean;
13
+ export declare function normalizeSkillLocation(location: string): string;
14
+ export declare function skillToolAdvertised(tools: readonly OpencodeToolDef[] | undefined): boolean;
15
+ /** Record skill files a Location's host currently registers (OpenCode 2 plugin). */
16
+ export declare function rememberHostSkillFiles(directory: string, skills: readonly HostSkillFileEntry[]): void;
17
+ export declare function hostSkillFiles(directory: string | undefined): ReadonlyMap<string, string> | undefined;
18
+ /** Test helper. */
19
+ export declare function resetHostSkillFilesForTests(): void;
20
+ /**
21
+ * path-desc `agent_skills` for cataloged skills that have a usable location,
22
+ * in catalog order. Descriptions come from the catalog (epoch-stable).
23
+ */
24
+ export declare function agentSkillsForCursor(systemText: string | undefined, options: {
25
+ skillToolAdvertised: boolean;
26
+ skillFiles?: ReadonlyMap<string, string>;
27
+ bridgeSkills?: readonly HostSkill[];
28
+ }): AgentSkillWire[];
29
+ /** Fetch locations from the structural bridge when installed. */
30
+ export declare function loadBridgeSkills(input: {
31
+ workspaceRoot: string;
32
+ sessionID?: string;
33
+ }): Promise<HostSkill[] | undefined>;
34
+ export declare function applyAgentSkillsToContext(context: Record<string, unknown>, skills: readonly AgentSkillWire[]): void;