opencode-hive 0.4.4 → 0.5.0
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/README.md +0 -2
- package/dist/e2e/opencode-runtime-smoke.test.d.ts +1 -0
- package/dist/e2e/opencode-runtime-smoke.test.js +243 -0
- package/dist/e2e/plugin-smoke.test.d.ts +1 -0
- package/dist/e2e/plugin-smoke.test.js +127 -0
- package/dist/index.js +273 -62
- package/dist/services/contextService.d.ts +15 -0
- package/dist/services/contextService.js +59 -0
- package/dist/services/featureService.d.ts +0 -2
- package/dist/services/featureService.js +1 -11
- package/dist/services/featureService.test.d.ts +1 -0
- package/dist/services/featureService.test.js +127 -0
- package/dist/services/planService.test.d.ts +1 -0
- package/dist/services/planService.test.js +115 -0
- package/dist/services/sessionService.d.ts +31 -0
- package/dist/services/sessionService.js +125 -0
- package/dist/services/taskService.d.ts +2 -1
- package/dist/services/taskService.js +87 -12
- package/dist/services/taskService.test.d.ts +1 -0
- package/dist/services/taskService.test.js +159 -0
- package/dist/services/worktreeService.js +42 -17
- package/dist/services/worktreeService.test.d.ts +1 -0
- package/dist/services/worktreeService.test.js +117 -0
- package/dist/tools/contextTools.d.ts +93 -0
- package/dist/tools/contextTools.js +83 -0
- package/dist/tools/execTools.js +8 -3
- package/dist/tools/featureTools.d.ts +0 -14
- package/dist/tools/featureTools.js +7 -18
- package/dist/tools/planTools.js +8 -3
- package/dist/tools/sessionTools.d.ts +35 -0
- package/dist/tools/sessionTools.js +95 -0
- package/dist/tools/taskTools.js +9 -4
- package/dist/types.d.ts +35 -0
- package/dist/utils/detection.d.ts +12 -0
- package/dist/utils/detection.js +73 -0
- package/dist/utils/paths.d.ts +1 -1
- package/dist/utils/paths.js +2 -3
- package/dist/utils/paths.test.d.ts +1 -0
- package/dist/utils/paths.test.js +100 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,9 +36,7 @@ npm install opencode-hive
|
|
|
36
36
|
|------|-------------|
|
|
37
37
|
| `hive_feature_create` | Create a new feature |
|
|
38
38
|
| `hive_feature_list` | List all features |
|
|
39
|
-
| `hive_feature_switch` | Switch to a feature |
|
|
40
39
|
| `hive_feature_complete` | Mark feature as complete |
|
|
41
|
-
| `hive_status` | Get feature overview |
|
|
42
40
|
|
|
43
41
|
### Planning
|
|
44
42
|
| Tool | Description |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { createServer } from "net";
|
|
5
|
+
import { createOpencodeClient, createOpencodeServer, } from "@opencode-ai/sdk";
|
|
6
|
+
const EXPECTED_TOOLS = [
|
|
7
|
+
"hive_feature_create",
|
|
8
|
+
"hive_plan_write",
|
|
9
|
+
"hive_plan_read",
|
|
10
|
+
"hive_tasks_sync",
|
|
11
|
+
"hive_exec_start",
|
|
12
|
+
];
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return !!value && typeof value === "object";
|
|
15
|
+
}
|
|
16
|
+
function firstKey(record) {
|
|
17
|
+
const keys = Object.keys(record);
|
|
18
|
+
return keys.length > 0 ? keys[0] : null;
|
|
19
|
+
}
|
|
20
|
+
async function getDefaultModel(client) {
|
|
21
|
+
const providers = (await client.provider.list());
|
|
22
|
+
if (!isRecord(providers))
|
|
23
|
+
return null;
|
|
24
|
+
const connected = providers.connected;
|
|
25
|
+
if (!Array.isArray(connected) || connected.length === 0)
|
|
26
|
+
return null;
|
|
27
|
+
const all = providers.all;
|
|
28
|
+
if (!Array.isArray(all))
|
|
29
|
+
return null;
|
|
30
|
+
const defaultMap = providers.default;
|
|
31
|
+
const providerID = typeof connected[0] === "string" ? connected[0] : null;
|
|
32
|
+
if (!providerID)
|
|
33
|
+
return null;
|
|
34
|
+
const providerEntry = all.find((p) => isRecord(p) && p.id === providerID);
|
|
35
|
+
if (!isRecord(providerEntry))
|
|
36
|
+
return null;
|
|
37
|
+
const models = providerEntry.models;
|
|
38
|
+
if (!isRecord(models))
|
|
39
|
+
return null;
|
|
40
|
+
const supportsToolCall = (modelInfo) => {
|
|
41
|
+
if (!isRecord(modelInfo))
|
|
42
|
+
return false;
|
|
43
|
+
const v = modelInfo.tool_call ?? modelInfo.toolcall;
|
|
44
|
+
return v === true;
|
|
45
|
+
};
|
|
46
|
+
const defaultModelID = isRecord(defaultMap) && typeof defaultMap[providerID] === "string"
|
|
47
|
+
? defaultMap[providerID]
|
|
48
|
+
: null;
|
|
49
|
+
if (defaultModelID && supportsToolCall(models[defaultModelID])) {
|
|
50
|
+
return { providerID, modelID: defaultModelID };
|
|
51
|
+
}
|
|
52
|
+
for (const modelID of Object.keys(models)) {
|
|
53
|
+
if (supportsToolCall(models[modelID])) {
|
|
54
|
+
return { providerID, modelID };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const fallback = defaultModelID ?? firstKey(models);
|
|
58
|
+
if (!fallback)
|
|
59
|
+
return null;
|
|
60
|
+
return { providerID, modelID: fallback };
|
|
61
|
+
}
|
|
62
|
+
async function getFreePort() {
|
|
63
|
+
return await new Promise((resolve, reject) => {
|
|
64
|
+
const server = createServer();
|
|
65
|
+
server.unref();
|
|
66
|
+
server.on("error", reject);
|
|
67
|
+
server.listen(0, "127.0.0.1", () => {
|
|
68
|
+
const address = server.address();
|
|
69
|
+
if (typeof address !== "object" || !address) {
|
|
70
|
+
server.close();
|
|
71
|
+
reject(new Error("Failed to get free port"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const port = address.port;
|
|
75
|
+
server.close(() => resolve(port));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function safeRm(dir) {
|
|
80
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
function pickHivePluginEntry() {
|
|
83
|
+
const distEntry = path.resolve(import.meta.dir, "..", "..", "dist", "index.js");
|
|
84
|
+
if (fs.existsSync(distEntry))
|
|
85
|
+
return distEntry;
|
|
86
|
+
const tsEntry = path.resolve(import.meta.dir, "..", "index.ts");
|
|
87
|
+
return tsEntry;
|
|
88
|
+
}
|
|
89
|
+
function extractStringArray(raw) {
|
|
90
|
+
if (Array.isArray(raw) && raw.every((v) => typeof v === "string"))
|
|
91
|
+
return raw;
|
|
92
|
+
if (isRecord(raw) && Array.isArray(raw.ids) && raw.ids.every((v) => typeof v === "string")) {
|
|
93
|
+
return raw.ids;
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
async function waitForTools(idsProvider, expected, timeoutMs) {
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
while (Date.now() < deadline) {
|
|
100
|
+
const ids = await idsProvider();
|
|
101
|
+
const ok = expected.every((t) => ids.includes(t));
|
|
102
|
+
if (ok)
|
|
103
|
+
return ids;
|
|
104
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
105
|
+
}
|
|
106
|
+
return await idsProvider();
|
|
107
|
+
}
|
|
108
|
+
describe("e2e: OpenCode runtime loads opencode-hive", () => {
|
|
109
|
+
it("exposes hive tools via /experimental/tool/ids", async () => {
|
|
110
|
+
const tmpBase = "/tmp/hive-e2e-runtime";
|
|
111
|
+
safeRm(tmpBase);
|
|
112
|
+
fs.mkdirSync(tmpBase, { recursive: true });
|
|
113
|
+
const projectDir = fs.mkdtempSync(path.join(tmpBase, "project-"));
|
|
114
|
+
fs.mkdirSync(path.join(projectDir, ".opencode", "plugin"), { recursive: true });
|
|
115
|
+
const hivePluginEntry = pickHivePluginEntry();
|
|
116
|
+
const pluginFile = path.join(projectDir, ".opencode", "plugin", "hive.ts");
|
|
117
|
+
const pluginSource = `import hive from ${JSON.stringify(hivePluginEntry)}\nexport const HivePlugin = hive\n`;
|
|
118
|
+
fs.writeFileSync(pluginFile, pluginSource);
|
|
119
|
+
const previousCwd = process.cwd();
|
|
120
|
+
const previousConfigDir = process.env.OPENCODE_CONFIG_DIR;
|
|
121
|
+
const previousDisableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS;
|
|
122
|
+
process.chdir(projectDir);
|
|
123
|
+
process.env.OPENCODE_CONFIG_DIR = path.join(projectDir, ".opencode");
|
|
124
|
+
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "true";
|
|
125
|
+
const port = await getFreePort();
|
|
126
|
+
const config = {
|
|
127
|
+
plugin: [],
|
|
128
|
+
logLevel: "ERROR",
|
|
129
|
+
};
|
|
130
|
+
const server = await createOpencodeServer({
|
|
131
|
+
hostname: "127.0.0.1",
|
|
132
|
+
port,
|
|
133
|
+
timeout: 20000,
|
|
134
|
+
config,
|
|
135
|
+
});
|
|
136
|
+
const client = createOpencodeClient({
|
|
137
|
+
baseUrl: server.url,
|
|
138
|
+
responseStyle: "data",
|
|
139
|
+
throwOnError: true,
|
|
140
|
+
directory: projectDir,
|
|
141
|
+
});
|
|
142
|
+
const abortController = new AbortController();
|
|
143
|
+
async function approvePermissions(sessionID) {
|
|
144
|
+
const sse = await client.event.subscribe({
|
|
145
|
+
query: { directory: projectDir },
|
|
146
|
+
signal: abortController.signal,
|
|
147
|
+
});
|
|
148
|
+
for await (const evt of sse.stream) {
|
|
149
|
+
if (!evt || typeof evt !== "object")
|
|
150
|
+
continue;
|
|
151
|
+
const maybeType = evt.type;
|
|
152
|
+
if (maybeType !== "permission.updated")
|
|
153
|
+
continue;
|
|
154
|
+
const properties = evt.properties;
|
|
155
|
+
if (!isRecord(properties))
|
|
156
|
+
continue;
|
|
157
|
+
if (properties.sessionID !== sessionID)
|
|
158
|
+
continue;
|
|
159
|
+
const permissionID = typeof properties.id === "string" ? properties.id : null;
|
|
160
|
+
if (!permissionID)
|
|
161
|
+
continue;
|
|
162
|
+
await client.postSessionIdPermissionsPermissionId({
|
|
163
|
+
path: { id: sessionID, permissionID },
|
|
164
|
+
body: { response: "once" },
|
|
165
|
+
query: { directory: projectDir },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const ids = await waitForTools(async () => {
|
|
171
|
+
const raw = (await client.tool.ids({ query: { directory: projectDir } }));
|
|
172
|
+
return extractStringArray(raw);
|
|
173
|
+
}, EXPECTED_TOOLS, 10000);
|
|
174
|
+
for (const toolName of EXPECTED_TOOLS) {
|
|
175
|
+
expect(ids).toContain(toolName);
|
|
176
|
+
}
|
|
177
|
+
const defaultModel = await getDefaultModel(client);
|
|
178
|
+
if (!defaultModel) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const session = (await client.session.create({
|
|
182
|
+
body: { title: "hive runtime e2e" },
|
|
183
|
+
query: { directory: projectDir },
|
|
184
|
+
}));
|
|
185
|
+
const sessionID = isRecord(session) && typeof session.id === "string" ? session.id : null;
|
|
186
|
+
expect(sessionID).not.toBeNull();
|
|
187
|
+
if (!sessionID)
|
|
188
|
+
return;
|
|
189
|
+
const permissionTask = approvePermissions(sessionID);
|
|
190
|
+
const promptResult = await client.session.prompt({
|
|
191
|
+
path: { id: sessionID },
|
|
192
|
+
query: { directory: projectDir },
|
|
193
|
+
body: {
|
|
194
|
+
model: defaultModel,
|
|
195
|
+
system: "Call the tool hive_feature_create exactly once with {\"name\":\"rt-feature\"}.",
|
|
196
|
+
tools: {
|
|
197
|
+
hive_feature_create: true,
|
|
198
|
+
},
|
|
199
|
+
parts: [
|
|
200
|
+
{
|
|
201
|
+
type: "text",
|
|
202
|
+
text: "Create a Hive feature named rt-feature.",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const hasToolPart = Array.isArray(promptResult?.parts)
|
|
208
|
+
? promptResult.parts.some((p) => isRecord(p) && p.type === "tool" && p.tool === "hive_feature_create")
|
|
209
|
+
: false;
|
|
210
|
+
if (!hasToolPart) {
|
|
211
|
+
abortController.abort();
|
|
212
|
+
await permissionTask.catch(() => undefined);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const featureDir = path.join(projectDir, ".hive", "features", "rt-feature");
|
|
216
|
+
const deadline = Date.now() + 20000;
|
|
217
|
+
while (Date.now() < deadline && !fs.existsSync(featureDir)) {
|
|
218
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
219
|
+
}
|
|
220
|
+
abortController.abort();
|
|
221
|
+
await permissionTask.catch(() => undefined);
|
|
222
|
+
expect(fs.existsSync(featureDir)).toBe(true);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
abortController.abort();
|
|
226
|
+
await server.close();
|
|
227
|
+
process.chdir(previousCwd);
|
|
228
|
+
if (previousConfigDir === undefined) {
|
|
229
|
+
delete process.env.OPENCODE_CONFIG_DIR;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
process.env.OPENCODE_CONFIG_DIR = previousConfigDir;
|
|
233
|
+
}
|
|
234
|
+
if (previousDisableDefault === undefined) {
|
|
235
|
+
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = previousDisableDefault;
|
|
239
|
+
}
|
|
240
|
+
safeRm(tmpBase);
|
|
241
|
+
}
|
|
242
|
+
}, 60000);
|
|
243
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
5
|
+
import plugin from "../index";
|
|
6
|
+
const OPENCODE_CLIENT = createOpencodeClient({ baseUrl: "http://localhost:1" });
|
|
7
|
+
const EXPECTED_TOOLS = [
|
|
8
|
+
"hive_feature_create",
|
|
9
|
+
"hive_feature_list",
|
|
10
|
+
"hive_feature_complete",
|
|
11
|
+
"hive_plan_write",
|
|
12
|
+
"hive_plan_read",
|
|
13
|
+
"hive_plan_approve",
|
|
14
|
+
"hive_tasks_sync",
|
|
15
|
+
"hive_task_create",
|
|
16
|
+
"hive_task_update",
|
|
17
|
+
"hive_exec_start",
|
|
18
|
+
"hive_exec_complete",
|
|
19
|
+
"hive_exec_abort",
|
|
20
|
+
];
|
|
21
|
+
const TEST_ROOT_BASE = "/tmp/hive-e2e-plugin";
|
|
22
|
+
function createStubShell() {
|
|
23
|
+
let shell;
|
|
24
|
+
const fn = ((..._args) => {
|
|
25
|
+
throw new Error("shell not available in this test");
|
|
26
|
+
});
|
|
27
|
+
shell = Object.assign(fn, {
|
|
28
|
+
braces(pattern) {
|
|
29
|
+
return [pattern];
|
|
30
|
+
},
|
|
31
|
+
escape(input) {
|
|
32
|
+
return input;
|
|
33
|
+
},
|
|
34
|
+
env() {
|
|
35
|
+
return shell;
|
|
36
|
+
},
|
|
37
|
+
cwd() {
|
|
38
|
+
return shell;
|
|
39
|
+
},
|
|
40
|
+
nothrow() {
|
|
41
|
+
return shell;
|
|
42
|
+
},
|
|
43
|
+
throws() {
|
|
44
|
+
return shell;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return shell;
|
|
48
|
+
}
|
|
49
|
+
function createToolContext(sessionID) {
|
|
50
|
+
return {
|
|
51
|
+
sessionID,
|
|
52
|
+
messageID: "msg_test",
|
|
53
|
+
agent: "test",
|
|
54
|
+
abort: new AbortController().signal,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function createProject(worktree) {
|
|
58
|
+
return {
|
|
59
|
+
id: "test",
|
|
60
|
+
worktree,
|
|
61
|
+
time: { created: Date.now() },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
describe("e2e: opencode-hive plugin (in-process)", () => {
|
|
65
|
+
let testRoot;
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
fs.rmSync(TEST_ROOT_BASE, { recursive: true, force: true });
|
|
68
|
+
fs.mkdirSync(TEST_ROOT_BASE, { recursive: true });
|
|
69
|
+
testRoot = fs.mkdtempSync(path.join(TEST_ROOT_BASE, "project-"));
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
fs.rmSync(TEST_ROOT_BASE, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
it("registers expected tools and basic workflow works", async () => {
|
|
75
|
+
const ctx = {
|
|
76
|
+
directory: testRoot,
|
|
77
|
+
worktree: testRoot,
|
|
78
|
+
serverUrl: new URL("http://localhost:1"),
|
|
79
|
+
project: createProject(testRoot),
|
|
80
|
+
client: OPENCODE_CLIENT,
|
|
81
|
+
$: createStubShell(),
|
|
82
|
+
};
|
|
83
|
+
const hooks = await plugin(ctx);
|
|
84
|
+
expect(hooks.tool).toBeDefined();
|
|
85
|
+
for (const toolName of EXPECTED_TOOLS) {
|
|
86
|
+
expect(hooks.tool?.[toolName]).toBeDefined();
|
|
87
|
+
expect(typeof hooks.tool?.[toolName].execute).toBe("function");
|
|
88
|
+
}
|
|
89
|
+
const sessionID = "sess_plugin_smoke";
|
|
90
|
+
const toolContext = createToolContext(sessionID);
|
|
91
|
+
const createOutput = await hooks.tool.hive_feature_create.execute({ name: "smoke-feature" }, toolContext);
|
|
92
|
+
expect(createOutput).toContain('Feature "smoke-feature" created');
|
|
93
|
+
const plan = `# Smoke Feature\n\n## Overview\n\nTest\n\n## Tasks\n\n### 1. First Task\nDo it\n`;
|
|
94
|
+
const planOutput = await hooks.tool.hive_plan_write.execute({ content: plan, feature: "smoke-feature" }, toolContext);
|
|
95
|
+
expect(planOutput).toContain("Plan written");
|
|
96
|
+
const approveOutput = await hooks.tool.hive_plan_approve.execute({ feature: "smoke-feature" }, toolContext);
|
|
97
|
+
expect(approveOutput).toContain("Plan approved");
|
|
98
|
+
const syncOutput = await hooks.tool.hive_tasks_sync.execute({ feature: "smoke-feature" }, toolContext);
|
|
99
|
+
expect(syncOutput).toContain("Tasks synced");
|
|
100
|
+
const taskFolder = path.join(testRoot, ".hive", "features", "smoke-feature", "tasks", "01-first-task");
|
|
101
|
+
expect(fs.existsSync(taskFolder)).toBe(true);
|
|
102
|
+
// Open a session to test session tracking
|
|
103
|
+
const sessionOutput = await hooks.tool.hive_session_open.execute({ feature: "smoke-feature" }, toolContext);
|
|
104
|
+
expect(sessionOutput).toContain("smoke-feature");
|
|
105
|
+
// Session is now stored in sessions.json via SessionService
|
|
106
|
+
const sessionsPath = path.join(testRoot, ".hive", "features", "smoke-feature", "sessions.json");
|
|
107
|
+
const sessions = JSON.parse(fs.readFileSync(sessionsPath, "utf-8"));
|
|
108
|
+
expect(sessions.master).toBe(sessionID);
|
|
109
|
+
});
|
|
110
|
+
it("system prompt hook injects Hive instructions", async () => {
|
|
111
|
+
const ctx = {
|
|
112
|
+
directory: testRoot,
|
|
113
|
+
worktree: testRoot,
|
|
114
|
+
serverUrl: new URL("http://localhost:1"),
|
|
115
|
+
project: createProject(testRoot),
|
|
116
|
+
client: OPENCODE_CLIENT,
|
|
117
|
+
$: createStubShell(),
|
|
118
|
+
};
|
|
119
|
+
const hooks = await plugin(ctx);
|
|
120
|
+
await hooks.tool.hive_feature_create.execute({ name: "active" }, createToolContext("sess"));
|
|
121
|
+
const output = { system: [] };
|
|
122
|
+
await hooks["experimental.chat.system.transform"]?.({}, output);
|
|
123
|
+
const joined = output.system.join("\n");
|
|
124
|
+
expect(joined).toContain("## Hive - Feature Development System");
|
|
125
|
+
expect(joined).toContain("hive_feature_create");
|
|
126
|
+
});
|
|
127
|
+
});
|