cursor-opencode-provider 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +28 -9
- package/SECURITY.md +43 -7
- package/dist/context/build.d.ts +22 -4
- package/dist/context/build.js +33 -5
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/frozen.js +6 -2
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +44 -17
- package/dist/context/paths.js +65 -18
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +119 -28
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/language-model.d.ts +45 -17
- package/dist/language-model.js +925 -292
- package/dist/model-config.d.ts +9 -0
- package/dist/model-config.js +28 -1
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +48 -40
- package/dist/plugin.js +69 -18
- package/dist/pricing-data.d.ts +14 -1
- package/dist/pricing-data.js +14 -1
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +74 -14
- package/dist/protocol/request.d.ts +5 -0
- package/dist/protocol/request.js +2 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +4 -0
- package/dist/protocol/tool-call-bridge.js +14 -0
- package/dist/protocol/tools.d.ts +16 -1
- package/dist/protocol/tools.js +113 -11
- package/dist/replay-safety.d.ts +6 -0
- package/dist/replay-safety.js +56 -6
- package/dist/session.d.ts +35 -1
- package/dist/session.js +45 -1
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +2 -0
- package/dist/transport/connect.js +23 -6
- package/dist/usage.js +23 -6
- package/package.json +3 -2
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
package/dist/context/paths.js
CHANGED
|
@@ -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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
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`).
|
|
@@ -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;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { skillsBridge, } from "./skills-bridge.js";
|
|
5
|
+
/**
|
|
6
|
+
* Cursor path-desc `agent_skills`: `{ full_path, description }` only, plus
|
|
7
|
+
* `agent_skills_info_complete: true`. Catalog text in the frozen system-
|
|
8
|
+
* instructions rule is the permission filter and order; locations come from
|
|
9
|
+
* the structural `opencode.host.skills` bridge, else OpenCode 2 `skill.list()`
|
|
10
|
+
* files remembered by the plugin, else OpenCode 1 catalog `<location>`.
|
|
11
|
+
*
|
|
12
|
+
* No content on the wire. Built-in / marker locations stay on the host `skill`
|
|
13
|
+
* tool only.
|
|
14
|
+
*/
|
|
15
|
+
// Plugin and language-model can load separate module graphs (see image-staging).
|
|
16
|
+
const HOST_SKILL_FILES = Symbol.for("cursor-opencode-provider.host-skill-files");
|
|
17
|
+
const globals = globalThis;
|
|
18
|
+
const byDirectory = globals[HOST_SKILL_FILES] ??= new Map();
|
|
19
|
+
const MAX_DIRECTORIES = 64;
|
|
20
|
+
const CATALOG = /<available_skills>([\s\S]*?)<\/available_skills>/;
|
|
21
|
+
// Names and descriptions are unescaped; OpenCode 1 escapes locations only.
|
|
22
|
+
const ENTRY_OC2 = /<skill>\s*<id>([\s\S]*?)<\/id>\s*<name>[\s\S]*?<\/name>\s*<description>([\s\S]*?)<\/description>\s*<\/skill>/g;
|
|
23
|
+
const ENTRY_OC1 = /<skill>\s*<name>([\s\S]*?)<\/name>\s*<description>([\s\S]*?)<\/description>\s*<location>([\s\S]*?)<\/location>\s*<\/skill>/g;
|
|
24
|
+
function isFile(file) {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(file).isFile();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function hasUriScheme(value) {
|
|
33
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
|
|
34
|
+
}
|
|
35
|
+
/** Locations Cursor can advertise; markers / builtins are omitted. */
|
|
36
|
+
export function isUsableSkillLocation(location) {
|
|
37
|
+
if (typeof location !== "string")
|
|
38
|
+
return false;
|
|
39
|
+
const value = location.trim();
|
|
40
|
+
if (!value)
|
|
41
|
+
return false;
|
|
42
|
+
if (value === "<built-in>" || value === "builtin")
|
|
43
|
+
return false;
|
|
44
|
+
if (value.startsWith("/builtin/"))
|
|
45
|
+
return false;
|
|
46
|
+
if (hasUriScheme(value)) {
|
|
47
|
+
if (value.startsWith("file:")) {
|
|
48
|
+
try {
|
|
49
|
+
const filePath = fileURLToPath(value);
|
|
50
|
+
return path.isAbsolute(filePath) && isFile(filePath);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Host URIs such as skill://name — keep for bridge hosts; no disk check.
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return path.isAbsolute(value) && isFile(value);
|
|
60
|
+
}
|
|
61
|
+
export function normalizeSkillLocation(location) {
|
|
62
|
+
const value = location.trim();
|
|
63
|
+
if (value.startsWith("file:")) {
|
|
64
|
+
try {
|
|
65
|
+
return fileURLToPath(value);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
export function skillToolAdvertised(tools) {
|
|
74
|
+
return (tools ?? []).some((tool) => tool?.name === "skill");
|
|
75
|
+
}
|
|
76
|
+
/** Record skill files a Location's host currently registers (OpenCode 2 plugin). */
|
|
77
|
+
export function rememberHostSkillFiles(directory, skills) {
|
|
78
|
+
const key = path.resolve(directory);
|
|
79
|
+
const files = new Map();
|
|
80
|
+
for (const skill of skills) {
|
|
81
|
+
if (typeof skill?.id !== "string" || typeof skill.path !== "string")
|
|
82
|
+
continue;
|
|
83
|
+
if (!path.isAbsolute(skill.path) || !isFile(skill.path))
|
|
84
|
+
continue;
|
|
85
|
+
files.set(skill.id, skill.path);
|
|
86
|
+
}
|
|
87
|
+
byDirectory.delete(key);
|
|
88
|
+
byDirectory.set(key, files);
|
|
89
|
+
while (byDirectory.size > MAX_DIRECTORIES) {
|
|
90
|
+
const oldest = byDirectory.keys().next().value;
|
|
91
|
+
if (oldest === undefined)
|
|
92
|
+
break;
|
|
93
|
+
byDirectory.delete(oldest);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function hostSkillFiles(directory) {
|
|
97
|
+
return directory ? byDirectory.get(path.resolve(directory)) : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Test helper. */
|
|
100
|
+
export function resetHostSkillFilesForTests() {
|
|
101
|
+
byDirectory.clear();
|
|
102
|
+
}
|
|
103
|
+
/** Undo OpenCode 1's escapeHtml once; literal entity text in paths must survive. */
|
|
104
|
+
function decodeCatalogLocation(value) {
|
|
105
|
+
const entities = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" };
|
|
106
|
+
return value.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => entities[entity]);
|
|
107
|
+
}
|
|
108
|
+
function parseCatalogEntries(systemText) {
|
|
109
|
+
const catalog = CATALOG.exec(systemText)?.[1];
|
|
110
|
+
if (!catalog)
|
|
111
|
+
return [];
|
|
112
|
+
const out = [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (const match of catalog.matchAll(ENTRY_OC2)) {
|
|
115
|
+
const id = match[1]?.trim();
|
|
116
|
+
const description = match[2] ?? "";
|
|
117
|
+
if (!id || seen.has(id))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(id);
|
|
120
|
+
out.push({ key: id, description: description.trim() });
|
|
121
|
+
}
|
|
122
|
+
if (out.length > 0)
|
|
123
|
+
return out;
|
|
124
|
+
for (const match of catalog.matchAll(ENTRY_OC1)) {
|
|
125
|
+
const name = match[1]?.trim();
|
|
126
|
+
const description = match[2] ?? "";
|
|
127
|
+
const location = match[3] ? decodeCatalogLocation(match[3].trim()) : undefined;
|
|
128
|
+
if (!name || seen.has(name))
|
|
129
|
+
continue;
|
|
130
|
+
seen.add(name);
|
|
131
|
+
out.push({
|
|
132
|
+
key: name,
|
|
133
|
+
description: description.trim(),
|
|
134
|
+
location: location || undefined,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function locationFromBridge(entry, bridgeSkills) {
|
|
140
|
+
if (!bridgeSkills?.length)
|
|
141
|
+
return undefined;
|
|
142
|
+
const byId = bridgeSkills.find((skill) => skill.id === entry.key && isUsableSkillLocation(skill.location));
|
|
143
|
+
if (byId?.location)
|
|
144
|
+
return normalizeSkillLocation(byId.location);
|
|
145
|
+
const byName = bridgeSkills.find((skill) => skill.name === entry.key && isUsableSkillLocation(skill.location));
|
|
146
|
+
if (byName?.location)
|
|
147
|
+
return normalizeSkillLocation(byName.location);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* path-desc `agent_skills` for cataloged skills that have a usable location,
|
|
152
|
+
* in catalog order. Descriptions come from the catalog (epoch-stable).
|
|
153
|
+
*/
|
|
154
|
+
export function agentSkillsForCursor(systemText, options) {
|
|
155
|
+
if (!options.skillToolAdvertised || !systemText)
|
|
156
|
+
return [];
|
|
157
|
+
const entries = parseCatalogEntries(systemText);
|
|
158
|
+
if (entries.length === 0)
|
|
159
|
+
return [];
|
|
160
|
+
const out = [];
|
|
161
|
+
const seenPaths = new Set();
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
let location = locationFromBridge(entry, options.bridgeSkills);
|
|
164
|
+
if (!location) {
|
|
165
|
+
const file = options.skillFiles?.get(entry.key);
|
|
166
|
+
if (file && isUsableSkillLocation(file))
|
|
167
|
+
location = file;
|
|
168
|
+
}
|
|
169
|
+
if (!location && entry.location && isUsableSkillLocation(entry.location)) {
|
|
170
|
+
location = normalizeSkillLocation(entry.location);
|
|
171
|
+
}
|
|
172
|
+
if (!location || seenPaths.has(location))
|
|
173
|
+
continue;
|
|
174
|
+
seenPaths.add(location);
|
|
175
|
+
out.push({ full_path: location, description: entry.description });
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** Fetch locations from the structural bridge when installed. */
|
|
180
|
+
export async function loadBridgeSkills(input) {
|
|
181
|
+
const bridge = skillsBridge();
|
|
182
|
+
if (!bridge)
|
|
183
|
+
return undefined;
|
|
184
|
+
try {
|
|
185
|
+
const listed = await bridge.list({
|
|
186
|
+
directory: path.resolve(input.workspaceRoot),
|
|
187
|
+
sessionID: input.sessionID,
|
|
188
|
+
});
|
|
189
|
+
if (!Array.isArray(listed))
|
|
190
|
+
return undefined;
|
|
191
|
+
return listed.filter((skill) => !!skill
|
|
192
|
+
&& typeof skill === "object"
|
|
193
|
+
&& typeof skill.name === "string"
|
|
194
|
+
&& typeof skill.description === "string");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export function applyAgentSkillsToContext(context, skills) {
|
|
201
|
+
if (skills.length === 0) {
|
|
202
|
+
delete context.agent_skills;
|
|
203
|
+
delete context.agent_skills_info_complete;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
context.agent_skills = skills.map((skill) => ({
|
|
207
|
+
full_path: skill.full_path,
|
|
208
|
+
description: skill.description,
|
|
209
|
+
}));
|
|
210
|
+
context.agent_skills_info_complete = true;
|
|
211
|
+
}
|
|
@@ -4,19 +4,68 @@
|
|
|
4
4
|
* The language model stays host-neutral: it records a canonical Cursor mode
|
|
5
5
|
* (`plan`, `spec`, `agent`, ...). A host entrypoint may install this structural
|
|
6
6
|
* callback when its public API can select the corresponding primary agent.
|
|
7
|
-
* OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents
|
|
8
|
-
*
|
|
7
|
+
* OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents
|
|
8
|
+
* and continues the turn via `session.synthetic` (OpenCode 2 `switchAgent`
|
|
9
|
+
* only publishes the agent and would otherwise idle).
|
|
10
|
+
* On OpenCode 1.x hosts, advertised `plan_enter` / `plan_exit` tools stay
|
|
11
|
+
* authoritative; without `plan_enter`, the classic entrypoint selects the
|
|
12
|
+
* `plan` agent the 1.x way, with a synthetic user message for that agent.
|
|
9
13
|
*/
|
|
10
14
|
export type HostAgentModeSwitchInput = {
|
|
11
15
|
sessionID: string;
|
|
12
16
|
targetModeID: string;
|
|
13
17
|
/** Concrete Cursor Run that owns the switch. */
|
|
14
18
|
cursorSessionID?: string;
|
|
19
|
+
/** Host primary agent the owning Run executed under, when the host reported it. */
|
|
20
|
+
hostAgent?: string;
|
|
15
21
|
};
|
|
16
22
|
export type HostAgentModeSwitchFn = (input: HostAgentModeSwitchInput) => void | Promise<void>;
|
|
17
|
-
export
|
|
18
|
-
/**
|
|
23
|
+
export type HostAgentModeSwitchOptions = {
|
|
24
|
+
/** The switch also starts the host turn that continues the conversation. */
|
|
25
|
+
resumesTurn?: boolean;
|
|
26
|
+
/** Queue-time filter: a request the switch would not apply stays provider-owned. */
|
|
27
|
+
accepts?: (input: HostAgentModeSwitchInput) => boolean;
|
|
28
|
+
};
|
|
29
|
+
/** Synthetic user text that carries an OpenCode 1.x primary-agent switch. */
|
|
30
|
+
export declare function hostAgentSwitchPromptText(agent: "plan" | "build"): string;
|
|
31
|
+
/**
|
|
32
|
+
* OpenCode 1.x shape of the switch: a 1.x session runs each turn under the
|
|
33
|
+
* agent of its latest user message, which is how the host's own plan_enter /
|
|
34
|
+
* plan_exit change agents.
|
|
35
|
+
*
|
|
36
|
+
* It accepts only a real transition of a session running one of the host's
|
|
37
|
+
* own primary agents: entering an existing `plan` agent from another primary
|
|
38
|
+
* agent, or leaving the observed `plan` agent. Subagent and hidden internal
|
|
39
|
+
* sessions (title, summary, compaction, task children) never get a user turn
|
|
40
|
+
* injected.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createPromptHostAgentModeSwitch(prompt: (input: {
|
|
43
|
+
sessionID: string;
|
|
44
|
+
agent: "plan" | "build";
|
|
45
|
+
text: string;
|
|
46
|
+
}) => Promise<unknown>, primaryAgents: () => ReadonlySet<string> | undefined): {
|
|
47
|
+
apply: HostAgentModeSwitchFn;
|
|
48
|
+
accepts: (input: HostAgentModeSwitchInput) => boolean;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Install this host's native switch; it is used until a newer one is
|
|
52
|
+
* installed. The returned disposer removes only this switch, so disposing an
|
|
53
|
+
* older setup never removes a newer setup's switch. `undefined` removes all.
|
|
54
|
+
*/
|
|
55
|
+
export declare function setHostAgentModeSwitch(fn: HostAgentModeSwitchFn | undefined, options?: HostAgentModeSwitchOptions): () => void;
|
|
56
|
+
/**
|
|
57
|
+
* How this host applies a Cursor mode switch to its primary agents: `resumes`
|
|
58
|
+
* when the switch also starts the next host turn, `next-turn` when the user's
|
|
59
|
+
* next message runs under the new agent, undefined without a native switch.
|
|
60
|
+
*/
|
|
61
|
+
export declare function hostAgentModeSwitchKind(): "resumes" | "next-turn" | undefined;
|
|
62
|
+
/** Queue only when this host installed a native primary-agent switch that accepts the request. */
|
|
19
63
|
export declare function queueHostAgentModeSwitch(input: HostAgentModeSwitchInput): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* True while an approved switch into the host `plan` agent waits for its Run to
|
|
66
|
+
* end and will itself start the plan-agent turn that continues the work.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isHostPlanEntryPending(sessionID: string | undefined): boolean;
|
|
20
69
|
export declare function cancelHostAgentModeSwitch(sessionID: string | undefined): void;
|
|
21
70
|
/**
|
|
22
71
|
* Apply the switch only after its Cursor Run is terminal and owns no pending
|
package/dist/host-agent-mode.js
CHANGED
|
@@ -4,28 +4,111 @@
|
|
|
4
4
|
* The language model stays host-neutral: it records a canonical Cursor mode
|
|
5
5
|
* (`plan`, `spec`, `agent`, ...). A host entrypoint may install this structural
|
|
6
6
|
* callback when its public API can select the corresponding primary agent.
|
|
7
|
-
* OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents
|
|
8
|
-
*
|
|
7
|
+
* OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents
|
|
8
|
+
* and continues the turn via `session.synthetic` (OpenCode 2 `switchAgent`
|
|
9
|
+
* only publishes the agent and would otherwise idle).
|
|
10
|
+
* On OpenCode 1.x hosts, advertised `plan_enter` / `plan_exit` tools stay
|
|
11
|
+
* authoritative; without `plan_enter`, the classic entrypoint selects the
|
|
12
|
+
* `plan` agent the 1.x way, with a synthetic user message for that agent.
|
|
9
13
|
*/
|
|
10
14
|
import { errorMessage, trace } from "./debug.js";
|
|
11
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Live switches, newest last. A host may set the plugin up more than once in
|
|
17
|
+
* one process (one setup per location instance, re-setup on plugin reload) and
|
|
18
|
+
* dispose an older setup after a newer one installed its switch.
|
|
19
|
+
*/
|
|
20
|
+
const registrations = [];
|
|
21
|
+
function activeSwitch() {
|
|
22
|
+
return registrations[registrations.length - 1];
|
|
23
|
+
}
|
|
12
24
|
const pending = new Map();
|
|
25
|
+
const applying = new Map();
|
|
13
26
|
const MAX_PENDING_HOST_AGENT_SWITCHES = 256;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
/** True for Cursor mode ids served by the host's `plan` primary agent. */
|
|
28
|
+
function isPlanModeID(targetModeID) {
|
|
29
|
+
const mode = targetModeID.trim().toLowerCase();
|
|
30
|
+
return mode === "plan" || mode === "spec";
|
|
31
|
+
}
|
|
32
|
+
/** Synthetic user text that carries an OpenCode 1.x primary-agent switch. */
|
|
33
|
+
export function hostAgentSwitchPromptText(agent) {
|
|
34
|
+
return agent === "plan"
|
|
35
|
+
? "Plan mode is now active. Continue planning the request above under the plan-mode " +
|
|
36
|
+
"instructions, and do not implement anything until the plan is approved."
|
|
37
|
+
: "Plan mode has ended. Continue with the approved work.";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* OpenCode 1.x shape of the switch: a 1.x session runs each turn under the
|
|
41
|
+
* agent of its latest user message, which is how the host's own plan_enter /
|
|
42
|
+
* plan_exit change agents.
|
|
43
|
+
*
|
|
44
|
+
* It accepts only a real transition of a session running one of the host's
|
|
45
|
+
* own primary agents: entering an existing `plan` agent from another primary
|
|
46
|
+
* agent, or leaving the observed `plan` agent. Subagent and hidden internal
|
|
47
|
+
* sessions (title, summary, compaction, task children) never get a user turn
|
|
48
|
+
* injected.
|
|
49
|
+
*/
|
|
50
|
+
export function createPromptHostAgentModeSwitch(prompt, primaryAgents) {
|
|
51
|
+
return {
|
|
52
|
+
accepts: ({ targetModeID, hostAgent }) => {
|
|
53
|
+
const agents = primaryAgents();
|
|
54
|
+
const current = hostAgent?.trim();
|
|
55
|
+
if (!agents || !current || !agents.has(current))
|
|
56
|
+
return false;
|
|
57
|
+
return isPlanModeID(targetModeID) ? current !== "plan" && agents.has("plan") : current === "plan";
|
|
58
|
+
},
|
|
59
|
+
apply: async ({ sessionID, targetModeID }) => {
|
|
60
|
+
const agent = isPlanModeID(targetModeID) ? "plan" : "build";
|
|
61
|
+
await prompt({ sessionID, agent, text: hostAgentSwitchPromptText(agent) });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Install this host's native switch; it is used until a newer one is
|
|
67
|
+
* installed. The returned disposer removes only this switch, so disposing an
|
|
68
|
+
* older setup never removes a newer setup's switch. `undefined` removes all.
|
|
69
|
+
*/
|
|
70
|
+
export function setHostAgentModeSwitch(fn, options = {}) {
|
|
71
|
+
if (!fn) {
|
|
72
|
+
registrations.length = 0;
|
|
17
73
|
pending.clear();
|
|
74
|
+
return () => { };
|
|
75
|
+
}
|
|
76
|
+
const registration = { fn, options };
|
|
77
|
+
registrations.push(registration);
|
|
78
|
+
return () => {
|
|
79
|
+
const index = registrations.lastIndexOf(registration);
|
|
80
|
+
if (index >= 0)
|
|
81
|
+
registrations.splice(index, 1);
|
|
82
|
+
if (registrations.length === 0)
|
|
83
|
+
pending.clear();
|
|
84
|
+
};
|
|
18
85
|
}
|
|
19
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* How this host applies a Cursor mode switch to its primary agents: `resumes`
|
|
88
|
+
* when the switch also starts the next host turn, `next-turn` when the user's
|
|
89
|
+
* next message runs under the new agent, undefined without a native switch.
|
|
90
|
+
*/
|
|
91
|
+
export function hostAgentModeSwitchKind() {
|
|
92
|
+
const active = activeSwitch();
|
|
93
|
+
if (!active)
|
|
94
|
+
return undefined;
|
|
95
|
+
return active.options.resumesTurn === true ? "resumes" : "next-turn";
|
|
96
|
+
}
|
|
97
|
+
/** Queue only when this host installed a native primary-agent switch that accepts the request. */
|
|
20
98
|
export function queueHostAgentModeSwitch(input) {
|
|
21
99
|
const sessionID = input.sessionID.trim();
|
|
22
100
|
const targetModeID = input.targetModeID.trim();
|
|
23
|
-
|
|
101
|
+
const active = activeSwitch();
|
|
102
|
+
if (!active || !sessionID || !targetModeID)
|
|
24
103
|
return false;
|
|
104
|
+
if (active.options.accepts && !active.options.accepts({ ...input, sessionID, targetModeID }))
|
|
105
|
+
return false;
|
|
106
|
+
const hostAgent = input.hostAgent?.trim();
|
|
25
107
|
pending.set(sessionID, {
|
|
26
108
|
sessionID,
|
|
27
109
|
targetModeID,
|
|
28
110
|
...(input.cursorSessionID ? { cursorSessionID: input.cursorSessionID } : {}),
|
|
111
|
+
...(hostAgent ? { hostAgent } : {}),
|
|
29
112
|
attempts: 0,
|
|
30
113
|
});
|
|
31
114
|
while (pending.size > MAX_PENDING_HOST_AGENT_SWITCHES) {
|
|
@@ -38,6 +121,17 @@ export function queueHostAgentModeSwitch(input) {
|
|
|
38
121
|
`cursorSessionID=${input.cursorSessionID ?? ""} target=${targetModeID}`);
|
|
39
122
|
return true;
|
|
40
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* True while an approved switch into the host `plan` agent waits for its Run to
|
|
126
|
+
* end and will itself start the plan-agent turn that continues the work.
|
|
127
|
+
*/
|
|
128
|
+
export function isHostPlanEntryPending(sessionID) {
|
|
129
|
+
if (activeSwitch()?.options.resumesTurn !== true)
|
|
130
|
+
return false;
|
|
131
|
+
const key = sessionID?.trim();
|
|
132
|
+
const target = key ? pending.get(key)?.targetModeID : undefined;
|
|
133
|
+
return target !== undefined && isPlanModeID(target);
|
|
134
|
+
}
|
|
41
135
|
export function cancelHostAgentModeSwitch(sessionID) {
|
|
42
136
|
const key = sessionID?.trim();
|
|
43
137
|
if (key)
|
|
@@ -53,7 +147,8 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
|
|
|
53
147
|
if (!key)
|
|
54
148
|
return false;
|
|
55
149
|
const state = pending.get(key);
|
|
56
|
-
|
|
150
|
+
const apply = activeSwitch()?.fn;
|
|
151
|
+
if (!state || !apply || applying.has(key))
|
|
57
152
|
return false;
|
|
58
153
|
if (options.terminal !== true || options.pumpActive || (options.pendingExecs ?? 0) > 0) {
|
|
59
154
|
return false;
|
|
@@ -70,9 +165,12 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
|
|
|
70
165
|
return false;
|
|
71
166
|
}
|
|
72
167
|
state.attempts += 1;
|
|
168
|
+
applying.set(key, state);
|
|
73
169
|
try {
|
|
74
|
-
await
|
|
75
|
-
|
|
170
|
+
await apply(state);
|
|
171
|
+
// A request queued during the callback belongs to a later transition.
|
|
172
|
+
if (pending.get(key) === state)
|
|
173
|
+
pending.delete(key);
|
|
76
174
|
trace(`host-agent-mode: switched sessionID=${key} target=${state.targetModeID}`);
|
|
77
175
|
return true;
|
|
78
176
|
}
|
|
@@ -80,13 +178,19 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
|
|
|
80
178
|
// Keep the request pending so a later explicit provider turn can retry at
|
|
81
179
|
// its own terminal boundary. The provider-owned mode reminder remains the
|
|
82
180
|
// behavioral fallback until the native host switch succeeds.
|
|
83
|
-
|
|
181
|
+
if (pending.get(key) === state)
|
|
182
|
+
delete state.cursorSessionID;
|
|
84
183
|
trace(`host-agent-mode: FAILED sessionID=${key} target=${state.targetModeID} ` +
|
|
85
184
|
`attempts=${state.attempts} err=${errorMessage(error)}`);
|
|
86
185
|
return false;
|
|
87
186
|
}
|
|
187
|
+
finally {
|
|
188
|
+
if (applying.get(key) === state)
|
|
189
|
+
applying.delete(key);
|
|
190
|
+
}
|
|
88
191
|
}
|
|
89
192
|
export function resetHostAgentModeSwitchForTests() {
|
|
90
|
-
|
|
193
|
+
registrations.length = 0;
|
|
91
194
|
pending.clear();
|
|
195
|
+
applying.clear();
|
|
92
196
|
}
|